From 7d0b4297b9a92c4369995c68fbc799cc099872ab Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:12:55 +1200 Subject: [PATCH 01/11] Convert :trigger to TriggerCommand (#392) Extract Program.Trigger into a real IFalloutCommand (async ExecuteAsync per the merged foundation contract), register the type directly, and drop the DelegateCommand shim + the Program.Trigger.cs partial. Test re-homed to Fallout.Cli.Specs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TriggerCommand.cs} | 18 +++++++--- src/Fallout.Cli/Program.cs | 2 +- .../Commands/TriggerCommandSpecs.cs | 35 +++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) rename src/Fallout.Cli/{Program.Trigger.cs => Commands/TriggerCommand.cs} (57%) create mode 100644 tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs diff --git a/src/Fallout.Cli/Program.Trigger.cs b/src/Fallout.Cli/Commands/TriggerCommand.cs similarity index 57% rename from src/Fallout.Cli/Program.Trigger.cs rename to src/Fallout.Cli/Commands/TriggerCommand.cs index 1497e5626..42486a80a 100644 --- a/src/Fallout.Cli/Program.Trigger.cs +++ b/src/Fallout.Cli/Commands/TriggerCommand.cs @@ -1,16 +1,24 @@ -ο»Ώusing System; -using System.Linq; +using System; +using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.Git; using Fallout.Common.IO; using Fallout.Common.Tools.Git; using Fallout.Common.Utilities; -namespace Fallout.Cli; +namespace Fallout.Cli.Commands; -partial class Program +/// +/// fallout :trigger: pushes an empty commit with the given message to trigger a remote build. +/// +public sealed class TriggerCommand : IFalloutCommand { - public static int Trigger(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public string Name => "trigger"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory)); + + private static int Execute(string[] args, AbsolutePath rootDirectory) { var repository = GitRepository.FromLocalDirectory(rootDirectory.NotNull()).NotNull("No Git repository"); Assert.NotNull(repository.Branch, "Git repository must not be detached"); diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 2221f1d9a..0c22aef61 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -54,6 +54,7 @@ private static void RegisterCommands(IServiceCollection services) { // Real command types β€” issue #392 converts one legacy handler per PR. services.AddSingleton(); + 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. @@ -65,7 +66,6 @@ private static void RegisterCommands(IServiceCollection services) 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))); diff --git a/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs new file mode 100644 index 000000000..665ad8a91 --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Fallout.Cli.Commands; +using Fallout.Common.IO; +using FluentAssertions; +using Xunit; + +namespace Fallout.Cli.Specs.Commands; + +public class TriggerCommandSpecs +{ + [Fact] + public void Name_IsTrigger() + => new TriggerCommand().Name.Should().Be("trigger"); + + [Fact] + public async Task Execute_OutsideGitRepository_Throws() + { + var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-trigger-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + try + { + var action = async () => await new TriggerCommand().ExecuteAsync(["a message"], dir, buildScript: null); + + // No resolvable Git repository at a throwaway temp dir β†’ the command fails rather than + // pushing anything. + await action.Should().ThrowAsync(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} From ce6b76beb3827b18cb8539a39cf55a50be433447 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:41:51 +1200 Subject: [PATCH 02/11] Add Fallout.Cli command-scaffolding services (#392) Extract the shared logic the converted commands depend on into injectable services: ConfigurationReader (build-script config parsing), PackageManager (package add/replace), BuildScaffolder (build scripts/solution wiring, incl. the .slnx writer), CakeConverter (Cake script conversion), and CliConventions/ToolBanner (banner + script-name helpers). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/BuildScaffolder.cs | 162 +++++++++++++++++++++++++ src/Fallout.Cli/CakeConverter.cs | 69 +++++++++++ src/Fallout.Cli/CliConventions.cs | 18 +++ src/Fallout.Cli/ConfigurationReader.cs | 40 ++++++ src/Fallout.Cli/PackageManager.cs | 35 ++++++ 5 files changed, 324 insertions(+) create mode 100644 src/Fallout.Cli/BuildScaffolder.cs create mode 100644 src/Fallout.Cli/CakeConverter.cs create mode 100644 src/Fallout.Cli/CliConventions.cs create mode 100644 src/Fallout.Cli/ConfigurationReader.cs create mode 100644 src/Fallout.Cli/PackageManager.cs diff --git a/src/Fallout.Cli/BuildScaffolder.cs b/src/Fallout.Cli/BuildScaffolder.cs new file mode 100644 index 000000000..ea55d5f51 --- /dev/null +++ b/src/Fallout.Cli/BuildScaffolder.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Xml.Linq; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using static Fallout.Common.Constants; +using static Fallout.Common.EnvironmentInfo; +using static Fallout.Common.Tooling.ProcessTasks; +using static Fallout.Common.Utilities.TemplateUtility; + +namespace Fallout.Cli; + +/// Writes the build scripts, configuration file and solution wiring when scaffolding a build. +internal interface IBuildScaffolder +{ + void WriteBuildScripts(AbsolutePath scriptDirectory, AbsolutePath rootDirectory); + void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile); + void UpdateSolutionFileContent(List content, string buildProjectFileRelative, string buildProjectGuid, string buildProjectName); + void UpdateSolutionXmlFileContent(XDocument content, string buildProjectFileRelative); + string[] GetTemplate(string templateName); +} + +/// +internal sealed class BuildScaffolder : IBuildScaffolder +{ + private const string PROJECT_KIND = "9A19103F-16F7-4668-BE54-9A1E7A4F7556"; + + public string[] GetTemplate(string templateName) + { + // Anchored to the Fallout.Cli root namespace where the templates/* resources are embedded. + return ResourceUtility.GetResourceAllLines($"templates.{templateName}"); + } + + public void WriteBuildScripts( + AbsolutePath scriptDirectory, + AbsolutePath rootDirectory) + { + (scriptDirectory / "build.sh").WriteAllLines( + FillTemplate( + GetTemplate("build.sh"), + tokens: GetDictionary( + new + { + RootDirectory = scriptDirectory.GetUnixRelativePathTo(rootDirectory), + })), + platformFamily: PlatformFamily.Linux); + + (scriptDirectory / "build.ps1").WriteAllLines( + FillTemplate( + GetTemplate("build.ps1"), + tokens: GetDictionary( + new + { + RootDirectory = scriptDirectory.GetWinRelativePathTo(rootDirectory), + })), + platformFamily: PlatformFamily.Windows); + + // .config/dotnet-tools.json pins Fallout.GlobalTools as a local tool so the thin shims + // (build.sh / build.ps1) can `dotnet tool restore` and `dotnet fallout` deterministically. + // Skip if the consumer already has a manifest β€” they may have other tools pinned and we + // don't want to clobber. They can add the `fallout.globaltools` entry manually. + var toolManifest = rootDirectory / ".config" / "dotnet-tools.json"; + if (!toolManifest.FileExists()) + { + (rootDirectory / ".config").CreateDirectory(); + toolManifest.WriteAllLines( + FillTemplate( + GetTemplate("dotnet-tools.json"), + tokens: GetDictionary( + new + { + FalloutCliVersion = typeof(BuildScaffolder).GetTypeInfo().Assembly.GetVersionText(), + }))); + } + + MakeExecutable(scriptDirectory / "build.sh"); + + void MakeExecutable(AbsolutePath scriptFile) + { + if (rootDirectory.ContainsDirectory(".git")) + StartProcess("git", $"update-index --add --chmod=+x {scriptFile}", logInvocation: false, logOutput: false); + + if (rootDirectory.ContainsDirectory(".svn")) + StartProcess("svn", $"propset svn:executable on {scriptFile}", logInvocation: false, logOutput: false); + + if (IsUnix) + StartProcess("chmod", $"+x {scriptFile}", logInvocation: false, logOutput: false); + } + } + + public void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) + { + var parametersFile = GetDefaultParametersFile(rootDirectory); + var dictionary = new Dictionary { ["$schema"] = BuildSchemaFileName }; + if (solutionFile != null) + dictionary["Solution"] = rootDirectory.GetUnixRelativePathTo(solutionFile).ToString(); + parametersFile.WriteJson(dictionary, JsonExtensions.DefaultSerializerOptions); + } + + public void UpdateSolutionFileContent( + List content, + string buildProjectFileRelative, + string buildProjectGuid, + string buildProjectName) + { + if (content.Any(x => x.Contains(buildProjectFileRelative))) + return; + + var globalIndex = content.IndexOf("Global"); + Assert.True(globalIndex != -1, "Could not find a 'Global' section in solution file"); + + var projectConfigurationIndex = content.FindIndex(x => x.Contains("GlobalSection(ProjectConfigurationPlatforms)")); + if (projectConfigurationIndex == -1) + { + var solutionConfigurationIndex = content.FindIndex(x => x.Contains("GlobalSection(SolutionConfigurationPlatforms)")); + if (solutionConfigurationIndex == -1) + { + content.Insert(globalIndex + 1, "\tGlobalSection(SolutionConfigurationPlatforms) = preSolution"); + content.Insert(globalIndex + 2, "\t\tDebug|Any CPU = Debug|Any CPU"); + content.Insert(globalIndex + 3, "\t\tRelease|Any CPU = Release|Any CPU"); + content.Insert(globalIndex + 4, "\tEndGlobalSection"); + + solutionConfigurationIndex = globalIndex + 1; + } + + var endGlobalSectionIndex = content.FindIndex(solutionConfigurationIndex, x => x.Contains("EndGlobalSection")); + + content.Insert(endGlobalSectionIndex + 1, "\tGlobalSection(ProjectConfigurationPlatforms) = postSolution"); + content.Insert(endGlobalSectionIndex + 2, "\tEndGlobalSection"); + + projectConfigurationIndex = endGlobalSectionIndex + 1; + } + + content.Insert(projectConfigurationIndex + 1, $"\t\t{{{buildProjectGuid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU"); + content.Insert(projectConfigurationIndex + 2, $"\t\t{{{buildProjectGuid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU"); + + content.Insert(globalIndex, + $"Project(\"{{{PROJECT_KIND}}}\") = \"{buildProjectName}\", \"{buildProjectFileRelative}\", \"{{{buildProjectGuid}}}\""); + content.Insert(globalIndex + 1, + "EndProject"); + } + + public void UpdateSolutionXmlFileContent(XDocument content, string buildProjectFileRelative) + { + var solutionElement = content.Root; + Assert.True(solutionElement?.Name == "Solution", "Could not find a root 'Solution' element in solution file"); + + // file uses forward slashes for paths on every platform + var path = buildProjectFileRelative.Replace(oldChar: '\\', newChar: '/'); + + if (solutionElement.Elements("Project").Any(x => x.GetAttributeValue("Path").EqualsOrdinalIgnoreCase(path))) + { + return; + } + + var projectElement = new XElement("Project", new XAttribute("Path", path)); + projectElement.Add(new XElement("Build", new XAttribute("Project", value: false))); + solutionElement.Add(projectElement); + } +} diff --git a/src/Fallout.Cli/CakeConverter.cs b/src/Fallout.Cli/CakeConverter.cs new file mode 100644 index 000000000..85ea20fa5 --- /dev/null +++ b/src/Fallout.Cli/CakeConverter.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Tooling; +using Fallout.Common.Utilities; +using Fallout.Cli.Rewriting.Cake; +using static Fallout.Common.Constants; +using static Fallout.Common.EnvironmentInfo; + +namespace Fallout.Cli; + +/// Best-effort syntax rewriting of Cake (*.cake) scripts into Fallout C#. +internal static class CakeConverter +{ + public const string FilePattern = "*.cake"; + + public static IEnumerable GetCakeFiles() + { + return (TryGetRootDirectoryFrom(WorkingDirectory) ?? WorkingDirectory).GlobFiles($"**/{FilePattern}"); + } + + public static string GetConvertedContent(string content) + { + var options = new CSharpParseOptions(LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Script); + var syntaxTree = CSharpSyntaxTree.ParseText(content, options); + return new CSharpSyntaxRewriter[] + { + new RemoveUsingDirectivesRewriter(), + new RenameFieldIdentifierRewriter(), + new ParameterRewriter(), + new AbsolutePathRewriter(), + new RegularFieldRewriter(), + new TargetDefinitionRewriter(), + new InvocationRewriter(), + new MemberAccessRewriter(), + new IdentifierNameRewriter(), + new ToolInvocationRewriter(), + new ClassRewriter(), + new FormattingRewriter() + }.Aggregate(syntaxTree.GetRoot(), (root, rewriter) => rewriter.Visit(root.NormalizeWhitespace(elasticTrivia: true))) + .ToFullString(); + } + + public static IEnumerable<(string Type, string Id, string Version)> GetPackages(string content) + { + IEnumerable<(string Type, string Id, string Version)> GetPackages( + string packageType, + string regexPattern) + { + var regex = new Regex(regexPattern); + foreach (Match match in regex.Matches(content)) + { + var packageId = match.Groups["packageId"].Value; + var packageVersion = match.Groups["version"].Value; + if (packageVersion.IsNullOrEmpty()) + packageVersion = AsyncHelper.RunSync(() => NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false)); + yield return new(packageType, packageId, packageVersion); + } + } + + return GetPackages(PackageManager.DownloadType, @"#tool ""nuget:\?package=(?'packageId'[\w\d\.]+)(&version=(?'version'[\w\d\.]+))?S*""") + .Concat(GetPackages(PackageManager.ReferenceType, @"#addin ""nuget:\?package=(?'packageId'[\w\d\.]+)(&version=(?'version'[\w\d\.]+))?S*""")) + .Where(x => !x.Id.ContainsOrdinalIgnoreCase("Cake")); + } +} diff --git a/src/Fallout.Cli/CliConventions.cs b/src/Fallout.Cli/CliConventions.cs new file mode 100644 index 000000000..0a10a02f9 --- /dev/null +++ b/src/Fallout.Cli/CliConventions.cs @@ -0,0 +1,18 @@ +using Fallout.Common; +using Fallout.Common.Utilities; + +namespace Fallout.Cli; + +/// Small CLI-wide conventions shared by the entry point and commands. +internal static class CliConventions +{ + /// The build-script file name for the current platform. + public static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; +} + +/// Prints the global-tool banner. +internal static class ToolBanner +{ + public static void Print() + => Host.Information($"Fallout Global Tool 🌐 {typeof(ToolBanner).Assembly.GetInformationalText()}"); +} diff --git a/src/Fallout.Cli/ConfigurationReader.cs b/src/Fallout.Cli/ConfigurationReader.cs new file mode 100644 index 000000000..5236be7a7 --- /dev/null +++ b/src/Fallout.Cli/ConfigurationReader.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Fallout.Common.IO; +using Fallout.Common.Utilities; + +namespace Fallout.Cli; + +/// Parses the # CONFIGURATION … # EXECUTION block out of a build script. +internal interface IConfigurationReader +{ + Dictionary Read(AbsolutePath buildScript, bool evaluate); +} + +/// +internal sealed class ConfigurationReader : IConfigurationReader +{ + /// Configuration key holding the build project file path. + public const string BuildProjectFileKey = "BUILD_PROJECT_FILE"; + + public Dictionary Read(AbsolutePath buildScript, bool evaluate) + { + string ReplaceScriptDirectory(string value) + => evaluate + ? value + .Replace("$SCRIPT_DIR", buildScript.Parent) + .Replace("$PSScriptRoot", buildScript.Parent) + : value; + + return File.ReadAllLines(buildScript) + .SkipWhile(x => !x.StartsWithOrdinalIgnoreCase("# CONFIGURATION")) + .TakeWhile(x => !x.StartsWithOrdinalIgnoreCase("# EXECUTION")) + .Where(x => !x.IsNullOrEmpty() && !x.StartsWithAny("#", "export ", "$env:")) + .Select(ReplaceScriptDirectory) + .Select(x => x.Split("=")) + .ToDictionary( + x => x.ElementAt(0).TrimStart("$").Trim().SplitCamelHumpsWithKnownWords().JoinUnderscore().ToUpperInvariant(), + x => x.ElementAt(1).Trim().TrimMatchingDoubleQuotes()); + } +} diff --git a/src/Fallout.Cli/PackageManager.cs b/src/Fallout.Cli/PackageManager.cs new file mode 100644 index 000000000..4c838ac2d --- /dev/null +++ b/src/Fallout.Cli/PackageManager.cs @@ -0,0 +1,35 @@ +using System.Linq; +using Fallout.Common; +using Fallout.Common.Utilities; +using Fallout.Solutions; + +namespace Fallout.Cli; + +/// Adds or replaces a package entry in the build project file. +internal interface IPackageManager +{ + void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile); +} + +/// +internal sealed class PackageManager : IPackageManager +{ + public const string DownloadType = "PackageDownload"; + public const string ReferenceType = "PackageReference"; + + public void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) + { + var buildProject = ProjectModelTasks.ParseProject(buildProjectFile).NotNull(); + + var previousPackage = buildProject.Items.SingleOrDefault(x => x.EvaluatedInclude == packageId); + if (previousPackage != null) + buildProject.RemoveItem(previousPackage); + + var packageDownloadItem = buildProject.AddItem(packageType, packageId).Single(); + packageDownloadItem.Xml.AddMetadata( + "Version", + packageType == ReferenceType ? packageVersion : $"[{packageVersion}]", + expressAsAttribute: true); + buildProject.Save(); + } +} From 4ab8d2c482e4fefb32bbbc7ade04a2c1423e5407 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:41:51 +1200 Subject: [PATCH 03/11] Convert remaining Cli commands to IFalloutCommand and collapse Program (#392) Convert complete, get-configuration, add-package, update, secrets, cake-convert, cake-clean, setup, and the navigation cluster into real IFalloutCommand types (async ExecuteAsync, DI-injected services), register them directly, and delete the DelegateCommand shims + every Program.X.cs partial. Program is now a thin entry point (DI wiring + root resolution). Commands and new CLI interfaces are internal, matching the merged foundation. Derived from main's current handlers, so the .slnx setup support, net10.0 target, and nuke->fallout rebrand are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/AddPackageCommand.cs | 61 ++++ src/Fallout.Cli/Commands/CakeCleanCommand.cs | 34 +++ .../Commands/CakeConvertCommand.cs | 95 ++++++ .../CompleteCommand.cs} | 15 +- src/Fallout.Cli/Commands/DelegateCommand.cs | 30 -- .../Commands/GetConfigurationCommand.cs | 33 ++ .../Navigation/GetNextDirectoryCommand.cs | 32 ++ .../Commands/Navigation/NavigationSession.cs | 49 +++ .../Navigation/PopDirectoryCommand.cs | 30 ++ .../PushWithChosenRootDirectoryCommand.cs | 38 +++ .../PushWithCurrentRootDirectoryCommand.cs | 20 ++ .../PushWithParentRootDirectoryCommand.cs | 23 ++ .../SecretsCommand.cs} | 30 +- src/Fallout.Cli/Commands/SetupCommand.cs | 162 ++++++++++ src/Fallout.Cli/Commands/TriggerCommand.cs | 2 +- src/Fallout.Cli/Commands/UpdateCommand.cs | 102 +++++++ src/Fallout.Cli/Program.AddPackage.cs | 63 ---- src/Fallout.Cli/Program.Cake.cs | 137 --------- src/Fallout.Cli/Program.GetConfiguration.cs | 51 ---- src/Fallout.Cli/Program.Navigation.cs | 101 ------- src/Fallout.Cli/Program.Setup.cs | 281 ------------------ src/Fallout.Cli/Program.Update.cs | 84 ------ src/Fallout.Cli/Program.cs | 63 ++-- 23 files changed, 737 insertions(+), 799 deletions(-) create mode 100644 src/Fallout.Cli/Commands/AddPackageCommand.cs create mode 100644 src/Fallout.Cli/Commands/CakeCleanCommand.cs create mode 100644 src/Fallout.Cli/Commands/CakeConvertCommand.cs rename src/Fallout.Cli/{Program.Complete.cs => Commands/CompleteCommand.cs} (73%) delete mode 100644 src/Fallout.Cli/Commands/DelegateCommand.cs create mode 100644 src/Fallout.Cli/Commands/GetConfigurationCommand.cs create mode 100644 src/Fallout.Cli/Commands/Navigation/GetNextDirectoryCommand.cs create mode 100644 src/Fallout.Cli/Commands/Navigation/NavigationSession.cs create mode 100644 src/Fallout.Cli/Commands/Navigation/PopDirectoryCommand.cs create mode 100644 src/Fallout.Cli/Commands/Navigation/PushWithChosenRootDirectoryCommand.cs create mode 100644 src/Fallout.Cli/Commands/Navigation/PushWithCurrentRootDirectoryCommand.cs create mode 100644 src/Fallout.Cli/Commands/Navigation/PushWithParentRootDirectoryCommand.cs rename src/Fallout.Cli/{Program.Secrets.cs => Commands/SecretsCommand.cs} (81%) create mode 100644 src/Fallout.Cli/Commands/SetupCommand.cs create mode 100644 src/Fallout.Cli/Commands/UpdateCommand.cs delete mode 100644 src/Fallout.Cli/Program.AddPackage.cs delete mode 100644 src/Fallout.Cli/Program.Cake.cs delete mode 100644 src/Fallout.Cli/Program.GetConfiguration.cs delete mode 100644 src/Fallout.Cli/Program.Navigation.cs delete mode 100644 src/Fallout.Cli/Program.Setup.cs delete mode 100644 src/Fallout.Cli/Program.Update.cs diff --git a/src/Fallout.Cli/Commands/AddPackageCommand.cs b/src/Fallout.Cli/Commands/AddPackageCommand.cs new file mode 100644 index 000000000..3fb4c1750 --- /dev/null +++ b/src/Fallout.Cli/Commands/AddPackageCommand.cs @@ -0,0 +1,61 @@ +using System.Linq; +using System.Threading.Tasks; +using Fallout.Common; +using Fallout.Common.Execution; +using Fallout.Common.IO; +using Fallout.Solutions; +using Fallout.Common.Tooling; +using Fallout.Common.Tools.DotNet; + +namespace Fallout.Cli.Commands; + +/// +/// fallout :add-package: adds (or upgrades) a NuGet package reference in the build project. +/// +internal sealed class AddPackageCommand : IFalloutCommand +{ + private readonly IConfigurationReader _configuration; + private readonly IPackageManager _packages; + + public AddPackageCommand(IConfigurationReader configuration, IPackageManager packages) + { + _configuration = configuration; + _packages = packages; + } + + public string Name => "add-package"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + ToolBanner.Print(); + Logging.Configure(); + Telemetry.AddPackage(); + ProjectModelTasks.Initialize(); + + var packageId = args.ElementAt(0); + var packageVersion = + (EnvironmentInfo.GetNamedArgument("version") ?? + args.ElementAtOrDefault(1) ?? + NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false).GetAwaiter().GetResult() ?? + NuGetPackageResolver.GetGlobalInstalledPackage(packageId, version: null, packagesConfigFile: null)?.Version.ToString()) + .NotNull("packageVersion != null"); + + var configuration = _configuration.Read(buildScript, evaluate: true); + var buildProjectFile = configuration[ConfigurationReader.BuildProjectFileKey]; + Host.Information($"Installing {packageId}/{packageVersion} to {buildProjectFile} ..."); + _packages.AddOrReplacePackage(packageId, packageVersion, PackageManager.DownloadType, buildProjectFile); + DotNetTasks.DotNet($"restore {buildProjectFile}"); + + var installedPackage = NuGetPackageResolver.GetGlobalInstalledPackage(packageId, packageVersion, packagesConfigFile: null) + .NotNull("installedPackage != null"); + var hasToolsDirectory = installedPackage.Directory.GlobDirectories("tools").Any(); + if (!hasToolsDirectory) + _packages.AddOrReplacePackage(packageId, packageVersion, PackageManager.ReferenceType, buildProjectFile); + + Host.Information($"Done installing {packageId}/{packageVersion} to {buildProjectFile}"); + return 0; + } +} diff --git a/src/Fallout.Cli/Commands/CakeCleanCommand.cs b/src/Fallout.Cli/Commands/CakeCleanCommand.cs new file mode 100644 index 000000000..895813833 --- /dev/null +++ b/src/Fallout.Cli/Commands/CakeCleanCommand.cs @@ -0,0 +1,34 @@ +using System.Linq; +using System.Threading.Tasks; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// fallout :cake-clean: lists and optionally deletes the *.cake scripts. +/// +internal sealed class CakeCleanCommand : IFalloutCommand +{ + private readonly IConsolePrompts _prompts; + + public CakeCleanCommand(IConsolePrompts prompts) => _prompts = prompts; + + public string Name => "cake-clean"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var cakeFiles = CakeConverter.GetCakeFiles().ToList(); + Host.Information("Found .cake files:"); + cakeFiles.ForEach(x => Host.Debug($" - {x}")); + + if (_prompts.PromptForConfirmation("Delete?")) + cakeFiles.ForEach(x => x.DeleteFile()); + + return 0; + } +} diff --git a/src/Fallout.Cli/Commands/CakeConvertCommand.cs b/src/Fallout.Cli/Commands/CakeConvertCommand.cs new file mode 100644 index 000000000..525a8b786 --- /dev/null +++ b/src/Fallout.Cli/Commands/CakeConvertCommand.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.Execution; +using Fallout.Common.IO; +using Fallout.Solutions; +using Fallout.Common.Utilities; +using static Fallout.Common.EnvironmentInfo; + +namespace Fallout.Cli.Commands; + +/// +/// fallout :cake-convert: best-effort conversion of *.cake scripts to Fallout C#. +/// +internal sealed class CakeConvertCommand : IFalloutCommand +{ + private readonly IConsolePrompts _prompts; + private readonly IConfigurationReader _configuration; + private readonly IPackageManager _packages; + private readonly SetupCommand _setup; + + public CakeConvertCommand( + IConsolePrompts prompts, + IConfigurationReader configuration, + IPackageManager packages, + SetupCommand setup) + { + _prompts = prompts; + _configuration = configuration; + _packages = packages; + _setup = setup; + } + + public string Name => "cake-convert"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + ToolBanner.Print(); + Logging.Configure(); + Telemetry.ConvertCake(); + ProjectModelTasks.Initialize(); + + Host.Warning( + new[] + { + "Converting .cake files is a best effort approach using syntax rewriting.", + "Compile errors are to be expected, however, the following elements are currently covered:", + " - Target definitions", + " - Default targets", + " - Parameter declarations", + " - Absolute paths", + " - Globbing patterns", + " - Tool invocations (dotnet CLI, SignTool)", + " - Addin and tool references", + }.JoinNewLine()); + + Host.Debug(); + if (!_prompts.PromptForConfirmation("Continue?")) + return 0; + Host.Debug(); + + if (buildScript == null && + _prompts.PromptForConfirmation("Should a NUKE project be created for better results?")) + { + _setup.ExecuteAsync(args, rootDirectory: null, buildScript: null).GetAwaiter().GetResult(); + } + + var buildScriptFile = WorkingDirectory / CliConventions.CurrentBuildScriptName; + var buildProjectFile = buildScriptFile.Exists() + ? _configuration.Read(buildScriptFile, evaluate: true) + .GetValueOrDefault(ConfigurationReader.BuildProjectFileKey, defaultValue: null) + : null; + + foreach (var cakeFile in CakeConverter.GetCakeFiles()) + { + var outputFile = cakeFile.Parent / cakeFile.NameWithoutExtension.Capitalize() + ".cs"; + var content = CakeConverter.GetConvertedContent(cakeFile.ReadAllText()); + outputFile.WriteAllText(content); + } + + if (buildProjectFile != null) + { + var packages = CakeConverter.GetCakeFiles().SelectMany(x => CakeConverter.GetPackages(x.ReadAllText())); + foreach (var package in packages) + _packages.AddOrReplacePackage(package.Id, package.Version, package.Type, buildProjectFile); + } + + return 0; + } +} diff --git a/src/Fallout.Cli/Program.Complete.cs b/src/Fallout.Cli/Commands/CompleteCommand.cs similarity index 73% rename from src/Fallout.Cli/Program.Complete.cs rename to src/Fallout.Cli/Commands/CompleteCommand.cs index d1af3e1fb..0ce8efc95 100644 --- a/src/Fallout.Cli/Program.Complete.cs +++ b/src/Fallout.Cli/Commands/CompleteCommand.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using System.Collections.Generic; using System.Linq; using Fallout.Common; @@ -7,13 +8,21 @@ using Fallout.Utilities.Text.Yaml; using static Fallout.Common.Constants; -namespace Fallout.Cli; +namespace Fallout.Cli.Commands; -partial class Program +/// +/// fallout :complete: emits shell-completion candidates for the partially typed command line. +/// +internal sealed class CompleteCommand : IFalloutCommand { private const string CommandName = "fallout"; - public static int Complete(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public string Name => "complete"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { if (rootDirectory == null) return 0; diff --git a/src/Fallout.Cli/Commands/DelegateCommand.cs b/src/Fallout.Cli/Commands/DelegateCommand.cs deleted file mode 100644 index 97089fab1..000000000 --- a/src/Fallout.Cli/Commands/DelegateCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Threading.Tasks; -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; - this.handler = handler; - } - - public string Name { get; } - - // 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/GetConfigurationCommand.cs b/src/Fallout.Cli/Commands/GetConfigurationCommand.cs new file mode 100644 index 000000000..256395edb --- /dev/null +++ b/src/Fallout.Cli/Commands/GetConfigurationCommand.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; + +namespace Fallout.Cli.Commands; + +/// +/// fallout :get-configuration: prints the build configuration parsed from the build script. +/// +internal sealed class GetConfigurationCommand : IFalloutCommand +{ + private readonly IConfigurationReader _configuration; + + public GetConfigurationCommand(IConfigurationReader configuration) => _configuration = configuration; + + public string Name => "get-configuration"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var configuration = _configuration.Read(buildScript.NotNull(), evaluate: false); + + Host.Information($"Configuration from {buildScript}:"); + configuration.ForEach(x => Console.WriteLine($"{x.Key} = {x.Value}")); + + return 0; + } +} diff --git a/src/Fallout.Cli/Commands/Navigation/GetNextDirectoryCommand.cs b/src/Fallout.Cli/Commands/Navigation/GetNextDirectoryCommand.cs new file mode 100644 index 000000000..d96fa323a --- /dev/null +++ b/src/Fallout.Cli/Commands/Navigation/GetNextDirectoryCommand.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using System.Linq; +using Fallout.Common; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands.Navigation; + +/// fallout :GetNextDirectory: prints (and consumes) the queued next directory. +internal sealed class GetNextDirectoryCommand : IFalloutCommand +{ + public string Name => "GetNextDirectory"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var content = NavigationSession.SessionFile.Existing()?.ReadAllLines(); + if (content == null || string.IsNullOrWhiteSpace(content[0])) + { + Console.WriteLine(EnvironmentInfo.WorkingDirectory); + return 1; + } + + var nextDirectory = content[0]; + content[0] = string.Empty; + NavigationSession.SessionFile.WriteAllLines(content); + Console.WriteLine(nextDirectory); + return 0; + } +} diff --git a/src/Fallout.Cli/Commands/Navigation/NavigationSession.cs b/src/Fallout.Cli/Commands/Navigation/NavigationSession.cs new file mode 100644 index 000000000..6b283a9f3 --- /dev/null +++ b/src/Fallout.Cli/Commands/Navigation/NavigationSession.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using static Fallout.Common.Constants; + +namespace Fallout.Cli.Commands.Navigation; + +/// +/// Shared per-terminal-session state for the directory-navigation commands. The companion shell +/// functions invoke the commands by name (preserved on conversion): +/// +/// function fallout- { fallout :PopDirectory; cd $(fallout :GetNextDirectory) } +/// function fallout/ { fallout :PushWithChosenRootDirectory; cd $(fallout :GetNextDirectory) } +/// function fallout. { fallout :PushWithCurrentRootDirectory; cd $(fallout :GetNextDirectory) } +/// function fallout.. { fallout :PushWithParentRootDirectory; cd $(fallout :GetNextDirectory) } +/// +/// +internal static class NavigationSession +{ + public static string SessionId + => EnvironmentInfo.Platform switch + { + PlatformFamily.OSX => EnvironmentInfo.GetVariable("TERM_SESSION_ID").NotNull()[7..], + PlatformFamily.Windows => EnvironmentInfo.GetVariable("WT_SESSION").NotNull(), + _ => throw new NotSupportedException($"{EnvironmentInfo.Platform} has no session id selector.") + }; + + public static AbsolutePath SessionFile => GlobalTemporaryDirectory / $"fallout-{SessionId}.dat"; + + public static int PushAndSetNext(Func directoryProvider) + { + try + { + var content = SessionFile.Existing()?.ReadAllLines().ToList() ?? new List { null }; + content[0] = directoryProvider.Invoke(); + content.Insert(index: 1, EnvironmentInfo.WorkingDirectory); + SessionFile.WriteAllLines(content); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + } +} diff --git a/src/Fallout.Cli/Commands/Navigation/PopDirectoryCommand.cs b/src/Fallout.Cli/Commands/Navigation/PopDirectoryCommand.cs new file mode 100644 index 000000000..933fd0ccd --- /dev/null +++ b/src/Fallout.Cli/Commands/Navigation/PopDirectoryCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; +using System.Linq; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands.Navigation; + +/// fallout :PopDirectory: pops the previous directory back to the front of the queue. +internal sealed class PopDirectoryCommand : IFalloutCommand +{ + public string Name => "PopDirectory"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var content = NavigationSession.SessionFile.Existing()?.ReadAllLines().ToList(); + if (content == null || content.Count <= 1) + { + Console.Error.WriteLine("No previous directory"); + return 1; + } + + content[0] = content[1]; + content.RemoveAt(1); + NavigationSession.SessionFile.WriteAllLines(content); + return 0; + } +} diff --git a/src/Fallout.Cli/Commands/Navigation/PushWithChosenRootDirectoryCommand.cs b/src/Fallout.Cli/Commands/Navigation/PushWithChosenRootDirectoryCommand.cs new file mode 100644 index 000000000..65c4e748b --- /dev/null +++ b/src/Fallout.Cli/Commands/Navigation/PushWithChosenRootDirectoryCommand.cs @@ -0,0 +1,38 @@ +using System.Linq; +using System.Threading.Tasks; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.IO; +using static Fallout.Common.Constants; + +namespace Fallout.Cli.Commands.Navigation; + +/// +/// fallout :PushWithChosenRootDirectory: prompts for a discovered root directory and queues it. +/// +internal sealed class PushWithChosenRootDirectoryCommand : IFalloutCommand +{ + private readonly IConsolePrompts _prompts; + + public PushWithChosenRootDirectoryCommand(IConsolePrompts prompts) => _prompts = prompts; + + public string Name => "PushWithChosenRootDirectory"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + return NavigationSession.PushAndSetNext(() => + { + var directories = EnvironmentInfo.WorkingDirectory.GlobDirectories($"**/{FalloutDirectoryName}") + .Concat(EnvironmentInfo.WorkingDirectory.GlobFiles($"**/{FalloutDirectoryName}")) + .Where(x => !x.Equals(EnvironmentInfo.WorkingDirectory)) + .Select(x => x.Parent) + .Select(x => (x, EnvironmentInfo.WorkingDirectory.GetRelativePathTo(x).ToString())) + .OrderBy(x => x.Item2).ToArray(); + + return _prompts.PromptForChoice("Where to go next?", directories); + }); + } +} diff --git a/src/Fallout.Cli/Commands/Navigation/PushWithCurrentRootDirectoryCommand.cs b/src/Fallout.Cli/Commands/Navigation/PushWithCurrentRootDirectoryCommand.cs new file mode 100644 index 000000000..2a2d6e482 --- /dev/null +++ b/src/Fallout.Cli/Commands/Navigation/PushWithCurrentRootDirectoryCommand.cs @@ -0,0 +1,20 @@ +using Fallout.Common; +using System.Threading.Tasks; +using Fallout.Common.IO; +using Fallout.Common.Utilities; + +namespace Fallout.Cli.Commands.Navigation; + +/// fallout :PushWithCurrentRootDirectory: queues the current root directory. +internal sealed class PushWithCurrentRootDirectoryCommand : IFalloutCommand +{ + public string Name => "PushWithCurrentRootDirectory"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + return NavigationSession.PushAndSetNext(() => rootDirectory.NotNull("No root directory")); + } +} diff --git a/src/Fallout.Cli/Commands/Navigation/PushWithParentRootDirectoryCommand.cs b/src/Fallout.Cli/Commands/Navigation/PushWithParentRootDirectoryCommand.cs new file mode 100644 index 000000000..e3dff3b80 --- /dev/null +++ b/src/Fallout.Cli/Commands/Navigation/PushWithParentRootDirectoryCommand.cs @@ -0,0 +1,23 @@ +using System.IO; +using System.Threading.Tasks; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using static Fallout.Common.Constants; + +namespace Fallout.Cli.Commands.Navigation; + +/// fallout :PushWithParentRootDirectory: queues the parent repository's root directory. +internal sealed class PushWithParentRootDirectoryCommand : IFalloutCommand +{ + public string Name => "PushWithParentRootDirectory"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + return NavigationSession.PushAndSetNext(() => TryGetRootDirectoryFrom(Path.GetDirectoryName(rootDirectory.NotNull("No root directory"))) + .NotNull("No parent root directory")); + } +} diff --git a/src/Fallout.Cli/Program.Secrets.cs b/src/Fallout.Cli/Commands/SecretsCommand.cs similarity index 81% rename from src/Fallout.Cli/Program.Secrets.cs rename to src/Fallout.Cli/Commands/SecretsCommand.cs index 9480566ef..dacc5befb 100644 --- a/src/Fallout.Cli/Program.Secrets.cs +++ b/src/Fallout.Cli/Commands/SecretsCommand.cs @@ -1,7 +1,7 @@ -using System; using System.Collections.Generic; +using System.Threading.Tasks; using System.Linq; -using System.Text.Json.Nodes; +using Fallout.Cli.Prompts; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; @@ -9,20 +9,34 @@ using static Fallout.Common.Constants; using static Fallout.Common.Utilities.EncryptionUtility; -namespace Fallout.Cli; +namespace Fallout.Cli.Commands; // TODO: unlock prompt // TODO: environment variable name // TODO: profile vs. environment // TODO: fallout :profile -partial class Program + +/// +/// fallout :secrets: interactively encrypts secret parameters into the (optionally profile-scoped) +/// parameters file, managing the keychain password. +/// +internal sealed class SecretsCommand : IFalloutCommand { private const string SaveAndExit = ""; private const string DiscardAndExit = ""; private const string DeletePasswordAndExit = ""; + private readonly IConsolePrompts _prompts; + + public SecretsCommand(IConsolePrompts prompts) => _prompts = prompts; + + public string Name => "secrets"; + // ReSharper disable once CognitiveComplexity - public static int Secrets(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { var secretParameters = CompletionUtility.GetItemsFromSchema( GetBuildSchemaFile(rootDirectory.NotNull("No root directory")), @@ -62,7 +76,7 @@ public static int Secrets(string[] args, AbsolutePath rootDirectory, AbsolutePat if (EnvironmentInfo.IsOsx && existingSecrets.Count == 0 && !fromCredentialStore) { - if (generatedPassword || PromptForConfirmation($"Save password to keychain? (associated with '{rootDirectory}')")) + if (generatedPassword || _prompts.PromptForConfirmation($"Save password to keychain? (associated with '{rootDirectory}')")) CredentialStore.SavePassword(credentialStoreName, password); } else if (fromLegacyCredentialStore) @@ -78,13 +92,13 @@ public static int Secrets(string[] args, AbsolutePath rootDirectory, AbsolutePat var addedSecrets = new Dictionary(); while (true) { - var choice = PromptForChoice( + var choice = _prompts.PromptForChoice( "Choose secret parameter to enter value:", options.Select(x => (x, addedSecrets.ContainsKey(x) || existingSecrets.ContainsKey(x) ? $"* {x}" : x)).ToArray()); if (!choice.EqualsAnyOrdinalIgnoreCase(SaveAndExit, DiscardAndExit, DeletePasswordAndExit)) { - addedSecrets[choice] = PromptForSecret(choice); + addedSecrets[choice] = _prompts.PromptForSecret(choice); } else { diff --git a/src/Fallout.Cli/Commands/SetupCommand.cs b/src/Fallout.Cli/Commands/SetupCommand.cs new file mode 100644 index 000000000..0ebfe9d77 --- /dev/null +++ b/src/Fallout.Cli/Commands/SetupCommand.cs @@ -0,0 +1,162 @@ +using System; +using System.Threading.Tasks; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.Execution; +using Fallout.Common.IO; +using Fallout.Common.Tooling; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; +using Spectre.Console; +using static Fallout.Common.Constants; +using static Fallout.Common.EnvironmentInfo; +using static Fallout.Common.Utilities.TemplateUtility; + +namespace Fallout.Cli.Commands; + +/// +/// fallout :setup: scaffolds a new build (build scripts, build project, configuration) interactively. +/// +internal sealed class SetupCommand : IFalloutCommand +{ + private const string TARGET_FRAMEWORK = "net10.0"; + + private readonly IConsolePrompts _prompts; + private readonly IBuildScaffolder _scaffolder; + + public SetupCommand(IConsolePrompts prompts, IBuildScaffolder scaffolder) + { + _prompts = prompts; + _scaffolder = scaffolder; + } + + public string Name => "setup"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + ToolBanner.Print(); + Logging.Configure(); + Telemetry.SetupBuild(); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[bold]Let's setup a new build![/]"); + AnsiConsole.WriteLine(); + + #region Basic + + var falloutLatestReleaseVersion = NuGetVersionResolver.GetLatestVersion(FalloutCommonPackageId, includePrereleases: false); + var falloutLatestPrereleaseVersion = NuGetVersionResolver.GetLatestVersion(FalloutCommonPackageId, includePrereleases: true); + var falloutLatestLocalVersion = NuGetPackageResolver.GetGlobalInstalledPackage(FalloutCommonPackageId, version: null, packagesConfigFile: null) + ?.Version.ToString(); + + if (rootDirectory == null) + rootDirectory = WorkingDirectory.FindParentOrSelf(x => x.ContainsDirectory(".git") || x.ContainsDirectory(".svn")); + + if (rootDirectory == null) + { + Host.Warning("Could not find root directory. Falling back to working directory ..."); + rootDirectory = WorkingDirectory; + } + _prompts.ShowInput("deciduous_tree", "Root directory", rootDirectory); + + var buildProjectName = _prompts.PromptForInput("How should the project be named?", "_build"); + _prompts.ClearPreviousLine(); + _prompts.ShowInput("bookmark", "Build project name", buildProjectName); + + var buildProjectRelativeDirectory = _prompts.PromptForInput("Where should the project be located?", "./build"); + _prompts.ClearPreviousLine(); + _prompts.ShowInput("round_pushpin", "Build project location", buildProjectRelativeDirectory); + + var falloutVersion = _prompts.PromptForChoice("Which Fallout.Common version should be used?", + new[] + { + ("latest release", falloutLatestReleaseVersion.GetAwaiter().GetResult()), + ("latest prerelease", falloutLatestPrereleaseVersion.GetAwaiter().GetResult()), + ("latest local", falloutLatestLocalVersion), + ("same as global tool", typeof(SetupCommand).GetTypeInfo().Assembly.GetVersionText()) + } + .Where(x => x.Item2 != null) + .Distinct(x => x.Item2) + .Select(x => (x.Item2, $"{x.Item2} ({x.Item1})")).ToArray()); + _prompts.ShowInput("gem_stone", "Fallout.Common version", falloutVersion); + + var solutionFile = (AbsolutePath) _prompts.PromptForChoice( + "Which solution should be the default?", + choices: new DirectoryInfo(rootDirectory) + .EnumerateFiles("*", SearchOption.AllDirectories) + .Where(x => x.FullName.EndsWithOrdinalIgnoreCase(".sln") || x.FullName.EndsWithOrdinalIgnoreCase(".slnx")) + .OrderByDescending(x => x.FullName) + .Select(x => (x, rootDirectory.GetRelativePathTo(x.FullName).ToString())) + .Concat((null, "None")).ToArray())?.FullName; + _prompts.ShowInput("toolbox", "Default solution", solutionFile != null ? rootDirectory.GetRelativePathTo(solutionFile) : ""); + + #endregion + + #region Generation + + var buildDirectory = rootDirectory / buildProjectRelativeDirectory; + var buildProjectFile = rootDirectory / buildProjectRelativeDirectory / buildProjectName + ".csproj"; + var buildProjectGuid = Guid.NewGuid().ToString().ToUpper(); + + (rootDirectory / FalloutDirectoryName).CreateDirectory(); + + _scaffolder.WriteBuildScripts( + scriptDirectory: WorkingDirectory, + rootDirectory); + + _scaffolder.WriteConfigurationFile(rootDirectory, solutionFile); + + if (solutionFile != null) + { + var buildProjectFileRelative = solutionFile.Parent.GetWinRelativePathTo(buildProjectFile); + if (solutionFile.Extension.EqualsOrdinalIgnoreCase(".slnx")) + { + var solutionDocument = XDocument.Load(solutionFile); + _scaffolder.UpdateSolutionXmlFileContent(solutionDocument, buildProjectFileRelative); + + var settings = new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true }; + using var writer = XmlWriter.Create(solutionFile, settings); + solutionDocument.Save(writer); + } + else + { + var solutionFileContent = solutionFile.ReadAllLines().ToList(); + _scaffolder.UpdateSolutionFileContent(solutionFileContent, buildProjectFileRelative, buildProjectGuid, buildProjectName); + solutionFile.WriteAllLines(solutionFileContent, Encoding.UTF8); + } + } + + buildProjectFile.WriteAllLines( + FillTemplate( + _scaffolder.GetTemplate("_build.csproj"), + GetDictionary( + new + { + RootDirectory = buildDirectory.GetWinRelativePathTo(rootDirectory), + ScriptDirectory = buildDirectory.GetWinRelativePathTo(WorkingDirectory), + TargetFramework = TARGET_FRAMEWORK, + TelemetryVersion = Telemetry.CurrentVersion, + FalloutVersion = falloutVersion, + }))); + + (buildDirectory / "Directory.Build.props").WriteAllLines(_scaffolder.GetTemplate("Directory.Build.props")); + (buildDirectory / "Directory.Build.targets").WriteAllLines(_scaffolder.GetTemplate("Directory.Build.targets")); + (buildDirectory / "Build.cs").WriteAllLines(FillTemplate(_scaffolder.GetTemplate("Build.cs"))); + (buildDirectory / "Configuration.cs").WriteAllLines(_scaffolder.GetTemplate("Configuration.cs")); + + #endregion + + _prompts.ShowCompletion("Setup"); + + return 0; + } +} diff --git a/src/Fallout.Cli/Commands/TriggerCommand.cs b/src/Fallout.Cli/Commands/TriggerCommand.cs index 42486a80a..ed2c2f595 100644 --- a/src/Fallout.Cli/Commands/TriggerCommand.cs +++ b/src/Fallout.Cli/Commands/TriggerCommand.cs @@ -11,7 +11,7 @@ namespace Fallout.Cli.Commands; /// /// fallout :trigger: pushes an empty commit with the given message to trigger a remote build. /// -public sealed class TriggerCommand : IFalloutCommand +internal sealed class TriggerCommand : IFalloutCommand { public string Name => "trigger"; diff --git a/src/Fallout.Cli/Commands/UpdateCommand.cs b/src/Fallout.Cli/Commands/UpdateCommand.cs new file mode 100644 index 000000000..310086c00 --- /dev/null +++ b/src/Fallout.Cli/Commands/UpdateCommand.cs @@ -0,0 +1,102 @@ +using System.IO; +using System.Threading.Tasks; +using System.Linq; +using System.Text.Json.Nodes; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.Execution; +using Fallout.Common.IO; +using Fallout.Solutions; +using Fallout.Common.Tools.DotNet; +using Fallout.Common.Utilities; +using static Fallout.Common.Constants; + +namespace Fallout.Cli.Commands; + +/// +/// fallout :update: updates the build scripts, build project, configuration file and global.json. +/// +internal sealed class UpdateCommand : IFalloutCommand +{ + private readonly IConsolePrompts _prompts; + private readonly IConfigurationReader _configuration; + private readonly IBuildScaffolder _scaffolder; + + public UpdateCommand(IConsolePrompts prompts, IConfigurationReader configuration, IBuildScaffolder scaffolder) + { + _prompts = prompts; + _configuration = configuration; + _scaffolder = scaffolder; + } + + public string Name => "update"; + + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(Execute(args, rootDirectory, buildScript)); + + private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + ToolBanner.Print(); + Logging.Configure(); + + Assert.NotNull(rootDirectory); + + if (buildScript != null) + { + _prompts.ConfirmExecution("Update build scripts", () => UpdateBuildScripts(rootDirectory, buildScript)); + _prompts.ConfirmExecution("Update build project", () => UpdateBuildProject(buildScript)); + } + + _prompts.ConfirmExecution("Update configuration file", () => UpdateConfigurationFile(rootDirectory)); + _prompts.ConfirmExecution("Update global.json", () => UpdateGlobalJsonFile(rootDirectory)); + + _prompts.ShowCompletion("Updates"); + + return 0; + } + + private void UpdateBuildScripts(AbsolutePath rootDirectory, AbsolutePath buildScript) + { + _scaffolder.WriteBuildScripts( + scriptDirectory: buildScript.Parent, + rootDirectory); + } + + private void UpdateBuildProject(AbsolutePath buildScript) + { + var configuration = _configuration.Read(buildScript, evaluate: true); + var projectFile = configuration[ConfigurationReader.BuildProjectFileKey]; + ProjectModelTasks.Initialize(); + ProjectUpdater.Update(projectFile); + } + + private void UpdateConfigurationFile(AbsolutePath rootDirectory) + { + var configurationFile = rootDirectory / FalloutDirectoryName; + if (!configurationFile.Exists()) + return; + + var solutionFile = rootDirectory / configurationFile.ReadAllLines().FirstOrDefault(x => !x.IsNullOrEmpty()); + configurationFile.DeleteFile(); + + _scaffolder.WriteConfigurationFile(rootDirectory, solutionFile); + Host.Warning($"The previous {FalloutFileName} file was transformed to a {FalloutDirectoryName} directory."); + Host.Warning($"The .tmp directory can be cleared, as it is moved to {FalloutDirectoryName}/temp as well."); + if (solutionFile != null) + Host.Warning($"Verify the property referencing the solution has the same name as the member with the {nameof(SolutionAttribute)}."); + } + + private static void UpdateGlobalJsonFile(AbsolutePath rootDirectory) + { + var latestInstalledSdk = DotNetTasks.DotNet("--list-sdks", logInvocation: false, logOutput: false) + .LastOrDefault().Text?.Split(" ").First(); + if (latestInstalledSdk == null) + return; + + var globalJsonFile = rootDirectory / "global.json"; + var jobject = globalJsonFile.Existing()?.ReadJsonObject() ?? new JsonObject(); + jobject["sdk"] ??= new JsonObject(); + jobject["sdk"].NotNull()["version"] = latestInstalledSdk; + globalJsonFile.WriteJson(jobject, JsonExtensions.DefaultSerializerOptions); + } +} diff --git a/src/Fallout.Cli/Program.AddPackage.cs b/src/Fallout.Cli/Program.AddPackage.cs deleted file mode 100644 index 29efb84be..000000000 --- a/src/Fallout.Cli/Program.AddPackage.cs +++ /dev/null @@ -1,63 +0,0 @@ -ο»Ώusing System; -using System.Linq; -using Fallout.Common; -using Fallout.Common.Execution; -using Fallout.Common.IO; -using Fallout.Solutions; -using Fallout.Common.Tooling; -using Fallout.Common.Tools.DotNet; - -namespace Fallout.Cli; - -partial class Program -{ - public const string PACKAGE_TYPE_DOWNLOAD = "PackageDownload"; - public const string PACKAGE_TYPE_REFERENCE = "PackageReference"; - - public static int AddPackage(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - PrintInfo(); - Logging.Configure(); - Telemetry.AddPackage(); - ProjectModelTasks.Initialize(); - - var packageId = args.ElementAt(0); - var packageVersion = - (EnvironmentInfo.GetNamedArgument("version") ?? - args.ElementAtOrDefault(1) ?? - NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false).GetAwaiter().GetResult() ?? - NuGetPackageResolver.GetGlobalInstalledPackage(packageId, version: null, packagesConfigFile: null)?.Version.ToString()) - .NotNull("packageVersion != null"); - - var configuration = GetConfiguration(buildScript, evaluate: true); - var buildProjectFile = configuration[BUILD_PROJECT_FILE]; - Host.Information($"Installing {packageId}/{packageVersion} to {buildProjectFile} ..."); - AddOrReplacePackage(packageId, packageVersion, PACKAGE_TYPE_DOWNLOAD, buildProjectFile); - DotNetTasks.DotNet($"restore {buildProjectFile}"); - - var installedPackage = NuGetPackageResolver.GetGlobalInstalledPackage(packageId, packageVersion, packagesConfigFile: null) - .NotNull("installedPackage != null"); - var hasToolsDirectory = installedPackage.Directory.GlobDirectories("tools").Any(); - if (!hasToolsDirectory) - AddOrReplacePackage(packageId, packageVersion, PACKAGE_TYPE_REFERENCE, buildProjectFile); - - Host.Information($"Done installing {packageId}/{packageVersion} to {buildProjectFile}"); - return 0; - } - - internal static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) - { - var buildProject = ProjectModelTasks.ParseProject(buildProjectFile).NotNull(); - - var previousPackage = buildProject.Items.SingleOrDefault(x => x.EvaluatedInclude == packageId); - if (previousPackage != null) - buildProject.RemoveItem(previousPackage); - - var packageDownloadItem = buildProject.AddItem(packageType, packageId).Single(); - packageDownloadItem.Xml.AddMetadata( - "Version", - packageType == PACKAGE_TYPE_REFERENCE ? packageVersion : $"[{packageVersion}]", - expressAsAttribute: true); - buildProject.Save(); - } -} diff --git a/src/Fallout.Cli/Program.Cake.cs b/src/Fallout.Cli/Program.Cake.cs deleted file mode 100644 index 6169798d1..000000000 --- a/src/Fallout.Cli/Program.Cake.cs +++ /dev/null @@ -1,137 +0,0 @@ -ο»Ώusing System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Fallout.Common; -using Fallout.Common.Execution; -using Fallout.Common.IO; -using Fallout.Solutions; -using Fallout.Common.Tooling; -using Fallout.Common.Utilities; -using Fallout.Cli.Rewriting.Cake; -using static Fallout.Common.Constants; -using static Fallout.Common.EnvironmentInfo; - -namespace Fallout.Cli; - -partial class Program -{ - public const string CAKE_FILE_PATTERN = "*.cake"; - - public static int CakeConvert(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - PrintInfo(); - Logging.Configure(); - Telemetry.ConvertCake(); - ProjectModelTasks.Initialize(); - - Host.Warning( - new[] - { - "Converting .cake files is a best effort approach using syntax rewriting.", - "Compile errors are to be expected, however, the following elements are currently covered:", - " - Target definitions", - " - Default targets", - " - Parameter declarations", - " - Absolute paths", - " - Globbing patterns", - " - Tool invocations (dotnet CLI, SignTool)", - " - Addin and tool references", - }.JoinNewLine()); - - Host.Debug(); - if (!PromptForConfirmation("Continue?")) - return 0; - Host.Debug(); - - if (buildScript == null && - PromptForConfirmation("Should a NUKE project be created for better results?")) - { - Setup(args, rootDirectory: null, buildScript: null); - } - - var buildScriptFile = WorkingDirectory / CurrentBuildScriptName; - var buildProjectFile = buildScriptFile.Exists() - ? GetConfiguration(buildScriptFile, evaluate: true) - .GetValueOrDefault(BUILD_PROJECT_FILE, defaultValue: null) - : null; - - foreach (var cakeFile in GetCakeFiles()) - { - var outputFile = cakeFile.Parent / cakeFile.NameWithoutExtension.Capitalize() + ".cs"; - var content = GetCakeConvertedContent(cakeFile.ReadAllText()); - outputFile.WriteAllText(content); - } - - if (buildProjectFile != null) - { - var packages = GetCakeFiles().SelectMany(x => GetCakePackages(x.ReadAllText())); - foreach (var package in packages) - AddOrReplacePackage(package.Id, package.Version, package.Type, buildProjectFile); - } - - return 0; - } - - public static int CakeClean(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - var cakeFiles = GetCakeFiles().ToList(); - Host.Information("Found .cake files:"); - cakeFiles.ForEach(x => Host.Debug($" - {x}")); - - if (PromptForConfirmation("Delete?")) - cakeFiles.ForEach(x => x.DeleteFile()); - - return 0; - } - - private static IEnumerable GetCakeFiles() - { - return (TryGetRootDirectoryFrom(WorkingDirectory) ?? WorkingDirectory).GlobFiles($"**/{CAKE_FILE_PATTERN}"); - } - - internal static string GetCakeConvertedContent(string content) - { - var options = new CSharpParseOptions(LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Script); - var syntaxTree = CSharpSyntaxTree.ParseText(content, options); - return new CSharpSyntaxRewriter[] - { - new RemoveUsingDirectivesRewriter(), - new RenameFieldIdentifierRewriter(), - new ParameterRewriter(), - new AbsolutePathRewriter(), - new RegularFieldRewriter(), - new TargetDefinitionRewriter(), - new InvocationRewriter(), - new MemberAccessRewriter(), - new IdentifierNameRewriter(), - new ToolInvocationRewriter(), - new ClassRewriter(), - new FormattingRewriter() - }.Aggregate(syntaxTree.GetRoot(), (root, rewriter) => rewriter.Visit(root.NormalizeWhitespace(elasticTrivia: true))) - .ToFullString(); - } - - internal static IEnumerable<(string Type, string Id, string Version)> GetCakePackages(string content) - { - IEnumerable<(string Type, string Id, string Version)> GetPackages( - string packageType, - string regexPattern) - { - var regex = new Regex(regexPattern); - foreach (Match match in regex.Matches(content)) - { - var packageId = match.Groups["packageId"].Value; - var packageVersion = match.Groups["version"].Value; - if (packageVersion.IsNullOrEmpty()) - packageVersion = AsyncHelper.RunSync(() => NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false)); - yield return new(packageType, packageId, packageVersion); - } - } - - return GetPackages(PACKAGE_TYPE_DOWNLOAD, @"#tool ""nuget:\?package=(?'packageId'[\w\d\.]+)(&version=(?'version'[\w\d\.]+))?S*""") - .Concat(GetPackages(PACKAGE_TYPE_REFERENCE, @"#addin ""nuget:\?package=(?'packageId'[\w\d\.]+)(&version=(?'version'[\w\d\.]+))?S*""")) - .Where(x => !x.Id.ContainsOrdinalIgnoreCase("Cake")); - } -} diff --git a/src/Fallout.Cli/Program.GetConfiguration.cs b/src/Fallout.Cli/Program.GetConfiguration.cs deleted file mode 100644 index 9a9875d95..000000000 --- a/src/Fallout.Cli/Program.GetConfiguration.cs +++ /dev/null @@ -1,51 +0,0 @@ -ο»Ώusing System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Fallout.Common; -using Fallout.Common.IO; -using Fallout.Common.Utilities; -using Fallout.Common.Utilities.Collections; - -namespace Fallout.Cli; - -partial class Program -{ - // 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); - private const string DOTNET_CHANNEL = nameof(DOTNET_CHANNEL); - - public static int GetConfiguration(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - var configuration = GetConfiguration(buildScript.NotNull(), evaluate: false); - - Host.Information($"Configuration from {buildScript}:"); - configuration.ForEach(x => Console.WriteLine($"{x.Key} = {x.Value}")); - - return 0; - } - - internal static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) - { - string ReplaceScriptDirectory(string value) - => evaluate - ? value - .Replace("$SCRIPT_DIR", buildScript.Parent) - .Replace("$PSScriptRoot", buildScript.Parent) - : value; - - return File.ReadAllLines(buildScript) - .SkipWhile(x => !x.StartsWithOrdinalIgnoreCase("# CONFIGURATION")) - .TakeWhile(x => !x.StartsWithOrdinalIgnoreCase("# EXECUTION")) - .Where(x => !x.IsNullOrEmpty() && !x.StartsWithAny("#", "export ", "$env:")) - .Select(ReplaceScriptDirectory) - .Select(x => x.Split("=")) - .ToDictionary( - x => x.ElementAt(0).TrimStart("$").Trim().SplitCamelHumpsWithKnownWords().JoinUnderscore().ToUpperInvariant(), - x => x.ElementAt(1).Trim().TrimMatchingDoubleQuotes()); - } -} diff --git a/src/Fallout.Cli/Program.Navigation.cs b/src/Fallout.Cli/Program.Navigation.cs deleted file mode 100644 index 17a369a2c..000000000 --- a/src/Fallout.Cli/Program.Navigation.cs +++ /dev/null @@ -1,101 +0,0 @@ -ο»Ώusing System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Fallout.Common; -using Fallout.Common.IO; -using static Fallout.Common.Constants; - -namespace Fallout.Cli; - -partial class Program -{ - // function fallout- { fallout :PopDirectory; cd $(fallout :GetNextDirectory) } - // function fallout/ { fallout :PushWithChosenRootDirectory; cd $(fallout :GetNextDirectory) } - // function fallout. { fallout :PushWithCurrentRootDirectory; cd $(fallout :GetNextDirectory) } - // function fallout.. { fallout :PushWithParentRootDirectory; cd $(fallout :GetNextDirectory) } - - private static string SessionId - => EnvironmentInfo.Platform switch - { - PlatformFamily.OSX => EnvironmentInfo.GetVariable("TERM_SESSION_ID").NotNull()[7..], - PlatformFamily.Windows => EnvironmentInfo.GetVariable("WT_SESSION").NotNull(), - _ => throw new NotSupportedException($"{EnvironmentInfo.Platform} has no session id selector.") - }; - - private static AbsolutePath SessionFile => GlobalTemporaryDirectory / $"fallout-{SessionId}.dat"; - - private static int GetNextDirectory() - { - var content = SessionFile.Existing()?.ReadAllLines(); - if (content == null || string.IsNullOrWhiteSpace(content[0])) - { - Console.WriteLine(EnvironmentInfo.WorkingDirectory); - return 1; - } - - var nextDirectory = content[0]; - content[0] = string.Empty; - SessionFile.WriteAllLines(content); - Console.WriteLine(nextDirectory); - return 0; - } - - private static int PopDirectory() - { - var content = SessionFile.Existing()?.ReadAllLines().ToList(); - if (content == null || content.Count <= 1) - { - Console.Error.WriteLine("No previous directory"); - return 1; - } - - content[0] = content[1]; - content.RemoveAt(1); - SessionFile.WriteAllLines(content); - return 0; - } - - private static int PushWithCurrentRootDirectory(AbsolutePath rootDirectory) - { - return PushAndSetNext(() => rootDirectory.NotNull("No root directory")); - } - - private static int PushWithParentRootDirectory(AbsolutePath rootDirectory) - { - return PushAndSetNext(() => TryGetRootDirectoryFrom(Path.GetDirectoryName(rootDirectory.NotNull("No root directory"))) - .NotNull("No parent root directory")); - } - - private static int PushWithChosenRootDirectory() - { - return PushAndSetNext(() => - { - var directories = EnvironmentInfo.WorkingDirectory.GlobDirectories($"**/{FalloutDirectoryName}") - .Concat(EnvironmentInfo.WorkingDirectory.GlobFiles($"**/{FalloutDirectoryName}")) - .Where(x => !x.Equals(EnvironmentInfo.WorkingDirectory)) - .Select(x => x.Parent) - .Select(x => (x, EnvironmentInfo.WorkingDirectory.GetRelativePathTo(x).ToString())) - .OrderBy(x => x.Item2).ToArray(); - - return PromptForChoice("Where to go next?", directories); - }); - } - - private static int PushAndSetNext(Func directoryProvider) - { - try - { - var content = SessionFile.Existing()?.ReadAllLines().ToList() ?? new List { null }; - content[0] = directoryProvider.Invoke(); - content.Insert(index: 1, EnvironmentInfo.WorkingDirectory); - SessionFile.WriteAllLines(content); - return 0; - } - catch (Exception exception) - { - Console.Error.WriteLine(exception.Message); - return 1; - } - } -} diff --git a/src/Fallout.Cli/Program.Setup.cs b/src/Fallout.Cli/Program.Setup.cs deleted file mode 100644 index b08005df3..000000000 --- a/src/Fallout.Cli/Program.Setup.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Xml; -using System.Xml.Linq; -using Fallout.Common; -using Fallout.Common.Execution; -using Fallout.Common.IO; -using Fallout.Common.Tooling; -using Fallout.Common.Utilities; -using Fallout.Common.Utilities.Collections; -using Spectre.Console; -using static Fallout.Common.Constants; -using static Fallout.Common.EnvironmentInfo; -using static Fallout.Common.Tooling.ProcessTasks; -using static Fallout.Common.Utilities.TemplateUtility; - -namespace Fallout.Cli; - -partial class Program -{ - // ReSharper disable InconsistentNaming - - private const string TARGET_FRAMEWORK = "net10.0"; - private const string PROJECT_KIND = "9A19103F-16F7-4668-BE54-9A1E7A4F7556"; - - // ReSharper disable once CognitiveComplexity - public static int Setup(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - PrintInfo(); - Logging.Configure(); - Telemetry.SetupBuild(); - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Let's setup a new build![/]"); - AnsiConsole.WriteLine(); - - #region Basic - - var falloutLatestReleaseVersion = NuGetVersionResolver.GetLatestVersion(FalloutCommonPackageId, includePrereleases: false); - var falloutLatestPrereleaseVersion = NuGetVersionResolver.GetLatestVersion(FalloutCommonPackageId, includePrereleases: true); - var falloutLatestLocalVersion = NuGetPackageResolver.GetGlobalInstalledPackage(FalloutCommonPackageId, version: null, packagesConfigFile: null) - ?.Version.ToString(); - - if (rootDirectory == null) - rootDirectory = WorkingDirectory.FindParentOrSelf(x => x.ContainsDirectory(".git") || x.ContainsDirectory(".svn")); - - if (rootDirectory == null) - { - Host.Warning("Could not find root directory. Falling back to working directory ..."); - rootDirectory = WorkingDirectory; - } - ShowInput("deciduous_tree", "Root directory", rootDirectory); - - var buildProjectName = PromptForInput("How should the project be named?", "_build"); - ClearPreviousLine(); - ShowInput("bookmark", "Build project name", buildProjectName); - - var buildProjectRelativeDirectory = PromptForInput("Where should the project be located?", "./build"); - ClearPreviousLine(); - ShowInput("round_pushpin", "Build project location", buildProjectRelativeDirectory); - - var falloutVersion = PromptForChoice("Which Fallout.Common version should be used?", - new[] - { - ("latest release", falloutLatestReleaseVersion.GetAwaiter().GetResult()), - ("latest prerelease", falloutLatestPrereleaseVersion.GetAwaiter().GetResult()), - ("latest local", falloutLatestLocalVersion), - ("same as global tool", typeof(Program).GetTypeInfo().Assembly.GetVersionText()) - } - .Where(x => x.Item2 != null) - .Distinct(x => x.Item2) - .Select(x => (x.Item2, $"{x.Item2} ({x.Item1})")).ToArray()); - ShowInput("gem_stone", "Fallout.Common version", falloutVersion); - - var solutionFile = (AbsolutePath) PromptForChoice( - "Which solution should be the default?", - choices: new DirectoryInfo(rootDirectory) - .EnumerateFiles("*", SearchOption.AllDirectories) - .Where(x => x.FullName.EndsWithOrdinalIgnoreCase(".sln") || x.FullName.EndsWithOrdinalIgnoreCase(".slnx")) - .OrderByDescending(x => x.FullName) - .Select(x => (x, rootDirectory.GetRelativePathTo(x.FullName).ToString())) - .Concat((null, "None")).ToArray())?.FullName; - ShowInput("toolbox", "Default solution", solutionFile != null ? rootDirectory.GetRelativePathTo(solutionFile) : ""); - - #endregion - - #region Generation - - var buildDirectory = rootDirectory / buildProjectRelativeDirectory; - var buildProjectFile = rootDirectory / buildProjectRelativeDirectory / buildProjectName + ".csproj"; - var buildProjectGuid = Guid.NewGuid().ToString().ToUpper(); - - (rootDirectory / FalloutDirectoryName).CreateDirectory(); - - WriteBuildScripts( - scriptDirectory: WorkingDirectory, - rootDirectory); - - WriteConfigurationFile(rootDirectory, solutionFile); - - if (solutionFile != null) - { - var buildProjectFileRelative = solutionFile.Parent.GetWinRelativePathTo(buildProjectFile); - if (solutionFile.Extension.EqualsOrdinalIgnoreCase(".slnx")) - { - var solutionDocument = XDocument.Load(solutionFile); - UpdateSolutionXmlFileContent(solutionDocument, buildProjectFileRelative); - - var settings = new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true }; - using var writer = XmlWriter.Create(solutionFile, settings); - solutionDocument.Save(writer); - } - else - { - var solutionFileContent = solutionFile.ReadAllLines().ToList(); - UpdateSolutionFileContent(solutionFileContent, buildProjectFileRelative, buildProjectGuid, buildProjectName); - solutionFile.WriteAllLines(solutionFileContent, Encoding.UTF8); - } - } - - buildProjectFile.WriteAllLines( - FillTemplate( - GetTemplate("_build.csproj"), - GetDictionary( - new - { - RootDirectory = buildDirectory.GetWinRelativePathTo(rootDirectory), - ScriptDirectory = buildDirectory.GetWinRelativePathTo(WorkingDirectory), - TargetFramework = TARGET_FRAMEWORK, - TelemetryVersion = Telemetry.CurrentVersion, - FalloutVersion = falloutVersion, - }))); - - (buildDirectory / "Directory.Build.props").WriteAllLines(GetTemplate("Directory.Build.props")); - (buildDirectory / "Directory.Build.targets").WriteAllLines(GetTemplate("Directory.Build.targets")); - (buildDirectory / "Build.cs").WriteAllLines(FillTemplate(GetTemplate("Build.cs"))); - (buildDirectory / "Configuration.cs").WriteAllLines(GetTemplate("Configuration.cs")); - - #endregion - - ShowCompletion("Setup"); - - return 0; - } - - internal static void UpdateSolutionFileContent( - List content, - string buildProjectFileRelative, - string buildProjectGuid, - string buildProjectName) - { - if (content.Any(x => x.Contains(buildProjectFileRelative))) - return; - - var globalIndex = content.IndexOf("Global"); - Assert.True(globalIndex != -1, "Could not find a 'Global' section in solution file"); - - var projectConfigurationIndex = content.FindIndex(x => x.Contains("GlobalSection(ProjectConfigurationPlatforms)")); - if (projectConfigurationIndex == -1) - { - var solutionConfigurationIndex = content.FindIndex(x => x.Contains("GlobalSection(SolutionConfigurationPlatforms)")); - if (solutionConfigurationIndex == -1) - { - content.Insert(globalIndex + 1, "\tGlobalSection(SolutionConfigurationPlatforms) = preSolution"); - content.Insert(globalIndex + 2, "\t\tDebug|Any CPU = Debug|Any CPU"); - content.Insert(globalIndex + 3, "\t\tRelease|Any CPU = Release|Any CPU"); - content.Insert(globalIndex + 4, "\tEndGlobalSection"); - - solutionConfigurationIndex = globalIndex + 1; - } - - var endGlobalSectionIndex = content.FindIndex(solutionConfigurationIndex, x => x.Contains("EndGlobalSection")); - - content.Insert(endGlobalSectionIndex + 1, "\tGlobalSection(ProjectConfigurationPlatforms) = postSolution"); - content.Insert(endGlobalSectionIndex + 2, "\tEndGlobalSection"); - - projectConfigurationIndex = endGlobalSectionIndex + 1; - } - - content.Insert(projectConfigurationIndex + 1, $"\t\t{{{buildProjectGuid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU"); - content.Insert(projectConfigurationIndex + 2, $"\t\t{{{buildProjectGuid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU"); - - content.Insert(globalIndex, - $"Project(\"{{{PROJECT_KIND}}}\") = \"{buildProjectName}\", \"{buildProjectFileRelative}\", \"{{{buildProjectGuid}}}\""); - content.Insert(globalIndex + 1, - "EndProject"); - } - - internal static void UpdateSolutionXmlFileContent(XDocument content, string buildProjectFileRelative) - { - var solutionElement = content.Root; - Assert.True(solutionElement?.Name == "Solution", "Could not find a root 'Solution' element in solution file"); - - // file uses forward slashes for paths on every platform - var path = buildProjectFileRelative.Replace(oldChar: '\\', newChar: '/'); - - if (solutionElement.Elements("Project").Any(x => x.GetAttributeValue("Path").EqualsOrdinalIgnoreCase(path))) - { - return; - } - - var projectElement = new XElement("Project", new XAttribute("Path", path)); - projectElement.Add(new XElement("Build", new XAttribute("Project", value: false))); - solutionElement.Add(projectElement); - } - - internal static string[] GetTemplate(string templateName) - { - return ResourceUtility.GetResourceAllLines($"templates.{templateName}"); - } - - internal static void WriteBuildScripts( - AbsolutePath scriptDirectory, - AbsolutePath rootDirectory) - { - (scriptDirectory / "build.sh").WriteAllLines( - FillTemplate( - GetTemplate("build.sh"), - tokens: GetDictionary( - new - { - RootDirectory = scriptDirectory.GetUnixRelativePathTo(rootDirectory), - })), - platformFamily: PlatformFamily.Linux); - - (scriptDirectory / "build.ps1").WriteAllLines( - FillTemplate( - GetTemplate("build.ps1"), - tokens: GetDictionary( - new - { - RootDirectory = scriptDirectory.GetWinRelativePathTo(rootDirectory), - })), - platformFamily: PlatformFamily.Windows); - - // .config/dotnet-tools.json pins Fallout.GlobalTools as a local tool so the thin shims - // (build.sh / build.ps1) can `dotnet tool restore` and `dotnet fallout` deterministically. - // Skip if the consumer already has a manifest β€” they may have other tools pinned and we - // don't want to clobber. They can add the `fallout.globaltools` entry manually. - var toolManifest = rootDirectory / ".config" / "dotnet-tools.json"; - if (!toolManifest.FileExists()) - { - (rootDirectory / ".config").CreateDirectory(); - toolManifest.WriteAllLines( - FillTemplate( - GetTemplate("dotnet-tools.json"), - tokens: GetDictionary( - new - { - FalloutCliVersion = typeof(Program).GetTypeInfo().Assembly.GetVersionText(), - }))); - } - - MakeExecutable(scriptDirectory / "build.sh"); - - void MakeExecutable(AbsolutePath scriptFile) - { - if (rootDirectory.ContainsDirectory(".git")) - StartProcess("git", $"update-index --add --chmod=+x {scriptFile}", logInvocation: false, logOutput: false); - - if (rootDirectory.ContainsDirectory(".svn")) - StartProcess("svn", $"propset svn:executable on {scriptFile}", logInvocation: false, logOutput: false); - - if (IsUnix) - StartProcess("chmod", $"+x {scriptFile}", logInvocation: false, logOutput: false); - } - } - - internal static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) - { - var parametersFile = GetDefaultParametersFile(rootDirectory); - var dictionary = new Dictionary { ["$schema"] = BuildSchemaFileName }; - if (solutionFile != null) - dictionary["Solution"] = rootDirectory.GetUnixRelativePathTo(solutionFile).ToString(); - parametersFile.WriteJson(dictionary, JsonExtensions.DefaultSerializerOptions); - } -} diff --git a/src/Fallout.Cli/Program.Update.cs b/src/Fallout.Cli/Program.Update.cs deleted file mode 100644 index 24a1c6eb3..000000000 --- a/src/Fallout.Cli/Program.Update.cs +++ /dev/null @@ -1,84 +0,0 @@ -ο»Ώusing System; -using System.Linq; -using System.Text.Json.Nodes; -using Fallout.Common; -using Fallout.Common.Execution; -using Fallout.Common.IO; -using Fallout.Solutions; -using Fallout.Common.Tools.DotNet; -using Fallout.Common.Utilities; -using static Fallout.Common.Constants; - -namespace Fallout.Cli; - -partial class Program -{ - public static int Update(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - PrintInfo(); - Logging.Configure(); - - Assert.NotNull(rootDirectory); - - if (buildScript != null) - { - ConfirmExecution("Update build scripts", () => UpdateBuildScripts(rootDirectory, buildScript)); - ConfirmExecution("Update build project", () => UpdateBuildProject(buildScript)); - } - - ConfirmExecution("Update configuration file", () => UpdateConfigurationFile(rootDirectory)); - ConfirmExecution("Update global.json", () => UpdateGlobalJsonFile(rootDirectory)); - - ShowCompletion("Updates"); - - return 0; - } - - private static void UpdateBuildScripts(AbsolutePath rootDirectory, AbsolutePath buildScript) - { - var configuration = GetConfiguration(buildScript, evaluate: true); - var buildProjectFile = (AbsolutePath) configuration[BUILD_PROJECT_FILE]; - - WriteBuildScripts( - scriptDirectory: buildScript.Parent, - rootDirectory); - } - - private static void UpdateBuildProject(AbsolutePath buildScript) - { - var configuration = GetConfiguration(buildScript, evaluate: true); - var projectFile = configuration[BUILD_PROJECT_FILE]; - ProjectModelTasks.Initialize(); - ProjectUpdater.Update(projectFile); - } - - private static void UpdateConfigurationFile(AbsolutePath rootDirectory) - { - var configurationFile = rootDirectory / FalloutDirectoryName; - if (!configurationFile.Exists()) - return; - - var solutionFile = rootDirectory / configurationFile.ReadAllLines().FirstOrDefault(x => !x.IsNullOrEmpty()); - configurationFile.DeleteFile(); - - WriteConfigurationFile(rootDirectory, solutionFile); - Host.Warning($"The previous {FalloutFileName} file was transformed to a {FalloutDirectoryName} directory."); - Host.Warning($"The .tmp directory can be cleared, as it is moved to {FalloutDirectoryName}/temp as well."); - if (solutionFile != null) - Host.Warning($"Verify the property referencing the solution has the same name as the member with the {nameof(SolutionAttribute)}."); - } - - private static void UpdateGlobalJsonFile(AbsolutePath rootDirectory) - { - var latestInstalledSdk = DotNetTasks.DotNet("--list-sdks", logInvocation: false, logOutput: false) - .LastOrDefault().Text?.Split(" ").First(); - if (latestInstalledSdk == null) - return; - - var globalJsonFile = rootDirectory / "global.json"; - var jobject = globalJsonFile.Existing()?.ReadJsonObject() ?? new JsonObject(); - jobject["sdk"] ??= new JsonObject(); - jobject["sdk"].NotNull()["version"] = latestInstalledSdk; - globalJsonFile.WriteJson(jobject, JsonExtensions.DefaultSerializerOptions); - } -} diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 0c22aef61..83108f2ea 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading.Tasks; using Fallout.Cli.Commands; +using Fallout.Cli.Commands.Navigation; using Fallout.Cli.Prompts; using Fallout.Common; using Fallout.Common.IO; @@ -14,8 +15,6 @@ namespace Fallout.Cli; public partial class Program { - internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; - private static async Task Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; @@ -25,7 +24,7 @@ private static async Task Main(string[] args) var rootDirectory = TryGetRootDirectory(); var buildScript = rootDirectory != null - ? rootDirectory.GetFiles(CurrentBuildScriptName, depth: 2) + ? rootDirectory.GetFiles(CliConventions.CurrentBuildScriptName, depth: 2) .FirstOrDefault(x => Constants.TryGetRootDirectoryFrom(x.Parent) == rootDirectory) : null; @@ -44,6 +43,9 @@ private static ServiceProvider BuildServiceProvider() var services = new ServiceCollection(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); RegisterCommands(services); @@ -52,30 +54,26 @@ private static ServiceProvider BuildServiceProvider() private static void RegisterCommands(IServiceCollection services) { - // Real command types β€” issue #392 converts one legacy handler per PR. services.AddSingleton(); 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("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())); - } - - internal static void PrintInfo() - { - Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // SetupCommand is also injected directly into CakeConvertCommand (cake conversion offers to + // scaffold a build first), so it is registered as a concrete singleton and surfaced as an + // IFalloutCommand through the same instance. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); } private static AbsolutePath TryGetRootDirectory() @@ -90,19 +88,4 @@ private static AbsolutePath TryGetRootDirectory() return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory()); } - - // ── 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); } From e2181ff45dd2a09484869dd0a0481e9abb5b295f Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:41:51 +1200 Subject: [PATCH 04/11] Re-home Cli command specs to Fallout.Cli.Specs (#392) Add async specs for the converted commands (Fallout.Cli.Specs/Commands, *Specs naming) plus a FakeConsolePrompts double and ConfigurationReader specs, and repoint the existing CakeConversion / UpdateSolutionFileContent specs at their new service homes (CakeConverter, PackageManager, BuildScaffolder). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Fallout.Cli.Specs/CakeConversionSpecs.cs | 10 +-- .../Commands/CakeCleanCommandSpecs.cs | 22 ++++++ .../Commands/CompleteCommandSpecs.cs | 36 ++++++++++ .../Commands/FakeConsolePrompts.cs | 35 +++++++++ .../Commands/GetConfigurationCommandSpecs.cs | 42 +++++++++++ .../Commands/SecretsCommandSpecs.cs | 23 ++++++ .../Commands/UpdateCommandSpecs.cs | 36 ++++++++++ .../ConfigurationReaderSpecs.cs | 72 +++++++++++++++++++ .../UpdateSolutionFileContentSpecs.cs | 4 +- 9 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs create mode 100644 tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs create mode 100644 tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs create mode 100644 tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs create mode 100644 tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs create mode 100644 tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs create mode 100644 tests/Fallout.Cli.Specs/ConfigurationReaderSpecs.cs diff --git a/tests/Fallout.Cli.Specs/CakeConversionSpecs.cs b/tests/Fallout.Cli.Specs/CakeConversionSpecs.cs index 9e999a6da..1a3f968ea 100644 --- a/tests/Fallout.Cli.Specs/CakeConversionSpecs.cs +++ b/tests/Fallout.Cli.Specs/CakeConversionSpecs.cs @@ -18,7 +18,7 @@ public class CakeConversionSpecs [MemberData(nameof(CakeFileNames))] public Task Test(AbsolutePath file) { - var converted = Program.GetCakeConvertedContent(file.ReadAllText()); + var converted = CakeConverter.GetConvertedContent(file.ReadAllText()); return Verifier.Verify(converted, extension: "cs") .UseDirectory(CakeScriptsDirectory) .UseFileName(file.NameWithoutExtension); @@ -29,9 +29,9 @@ public void TestPackages() { var content = (CakeScriptsDirectory / "references.cake").ReadAllText(); - var packages = Program.GetCakePackages(content).ToList(); - packages.Should().Contain((Program.PACKAGE_TYPE_DOWNLOAD, "GitVersion.CommandLine", "4.0.0")); - packages.Should().Contain((Program.PACKAGE_TYPE_REFERENCE, "SharpZipLib", "1.2.0")); + var packages = CakeConverter.GetPackages(content).ToList(); + packages.Should().Contain((PackageManager.DownloadType, "GitVersion.CommandLine", "4.0.0")); + packages.Should().Contain((PackageManager.ReferenceType, "SharpZipLib", "1.2.0")); packages.Should().Contain(x => x.Id == "TeamCity.Dotnet.Integration" && NuGetVersion.Parse(x.Version) > NuGetVersion.Parse("1.0.10")); packages.Should().NotContain(x => x.Id.Contains("Cake")); @@ -40,5 +40,5 @@ public void TestPackages() private static AbsolutePath CakeScriptsDirectory => RootDirectory / "tests" / "Fallout.Cli.Specs" / "cake-scripts"; public static IEnumerable CakeFileNames - => CakeScriptsDirectory.GlobFiles(Program.CAKE_FILE_PATTERN).Select(x => new object[] { x }); + => CakeScriptsDirectory.GlobFiles(CakeConverter.FilePattern).Select(x => new object[] { x }); } diff --git a/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs new file mode 100644 index 000000000..b0666ab66 --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs @@ -0,0 +1,22 @@ +using Fallout.Cli.Commands; +using FluentAssertions; +using System.Threading.Tasks; +using Xunit; + +namespace Fallout.Cli.Specs.Commands; + +public class CakeCleanCommandSpecs +{ + [Fact] + public void Name_IsCakeClean() + => new CakeCleanCommand(new FakeConsolePrompts()).Name.Should().Be("cake-clean"); + + [Fact] + public async Task Execute_WhenDeletionDeclined_ReturnsZeroAndDeletesNothing() + { + // ConfirmationResult = false β†’ the "Delete?" prompt is declined, so no .cake files are removed. + var prompts = new FakeConsolePrompts { ConfirmationResult = false }; + + (await new CakeCleanCommand(prompts).ExecuteAsync([], rootDirectory: null, buildScript: null)).Should().Be(0); + } +} diff --git a/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs new file mode 100644 index 000000000..febd2d8ca --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using Fallout.Cli.Commands; +using Fallout.Common.IO; +using FluentAssertions; +using System.Threading.Tasks; +using Xunit; + +namespace Fallout.Cli.Specs.Commands; + +public class CompleteCommandSpecs +{ + [Fact] + public void Name_IsComplete() + => new CompleteCommand().Name.Should().Be("complete"); + + [Fact] + public async Task Execute_WithoutRootDirectory_ReturnsZero() + => (await new CompleteCommand().ExecuteAsync(["fallout "], rootDirectory: null, buildScript: null)).Should().Be(0); + + [Fact] + public async Task Execute_WordNotStartingWithCommandName_ReturnsZero() + { + var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-complete-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + try + { + // Completion only fires for the `fallout` command line; anything else short-circuits to 0. + (await new CompleteCommand().ExecuteAsync(["notfallout foo"], dir, buildScript: null)).Should().Be(0); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs b/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs new file mode 100644 index 000000000..ecdd13d31 --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Fallout.Cli.Prompts; + +namespace Fallout.Cli.Specs.Commands; + +/// +/// Configurable test double for exercising command interaction logic +/// without a real console. +/// +internal sealed class FakeConsolePrompts : IConsolePrompts +{ + /// Answer returned by . + public bool ConfirmationResult { get; init; } + + /// When false, does not run its action (simulates a decline). + public bool InvokeConfirmedActions { get; init; } + + /// Titles passed to , in order. + public List Completions { get; } = new(); + + public bool PromptForConfirmation(string question) => ConfirmationResult; + public void ShowInput(string emoji, string title, string value) { } + public void ShowCompletion(string title) => Completions.Add(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) + { + if (InvokeConfirmedActions) + action(); + } +} diff --git a/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs new file mode 100644 index 000000000..10331a353 --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using Fallout.Cli.Commands; +using Fallout.Common.IO; +using FluentAssertions; +using System.Threading.Tasks; +using Xunit; + +namespace Fallout.Cli.Specs.Commands; + +public class GetConfigurationCommandSpecs +{ + [Fact] + public void Name_IsGetConfiguration() + => new GetConfigurationCommand(new ConfigurationReader()).Name.Should().Be("get-configuration"); + + [Fact] + public async Task Execute_ParsesConfigurationBlock_ReturnsZero() + { + var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-getcfg-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + var buildScript = dir / "build.sh"; + try + { + File.WriteAllText(buildScript, string.Join("\n", + "# CONFIGURATION", + "##############", + "", + "BUILD_PROJECT_FILE=\"build/_build.csproj\"", + "TEMP_DIRECTORY=\"$SCRIPT_DIR/.fallout/temp\"", + "", + "# EXECUTION", + "dotnet run")); + + (await new GetConfigurationCommand(new ConfigurationReader()).ExecuteAsync([], dir, buildScript)).Should().Be(0); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs new file mode 100644 index 000000000..13bb53d2a --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs @@ -0,0 +1,23 @@ +using System; +using Fallout.Cli.Commands; +using FluentAssertions; +using System.Threading.Tasks; +using Xunit; + +namespace Fallout.Cli.Specs.Commands; + +public class SecretsCommandSpecs +{ + [Fact] + public void Name_IsSecrets() + => new SecretsCommand(new FakeConsolePrompts()).Name.Should().Be("secrets"); + + [Fact] + public async Task Execute_WithoutRootDirectory_Throws() + { + var action = async () => await new SecretsCommand(new FakeConsolePrompts()) + .ExecuteAsync([], rootDirectory: null, buildScript: null); + + await action.Should().ThrowAsync().WithMessage("*No root directory*"); + } +} diff --git a/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs new file mode 100644 index 000000000..274c10b98 --- /dev/null +++ b/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using Fallout.Cli.Commands; +using Fallout.Common.IO; +using FluentAssertions; +using System.Threading.Tasks; +using Xunit; + +namespace Fallout.Cli.Specs.Commands; + +public class UpdateCommandSpecs +{ + [Fact] + public void Name_IsUpdate() + => new UpdateCommand(new FakeConsolePrompts(), new ConfigurationReader(), new BuildScaffolder()).Name.Should().Be("update"); + + [Fact] + public async Task Execute_NoBuildScript_DeclineAll_ReturnsZeroAndReportsCompletion() + { + var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-update-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + var prompts = new FakeConsolePrompts { InvokeConfirmedActions = false }; + try + { + // No build script and every confirmation declined β†’ no update steps run, but the command + // still completes cleanly. + (await new UpdateCommand(prompts, new ConfigurationReader(), new BuildScaffolder()).ExecuteAsync([], dir, buildScript: null)).Should().Be(0); + + prompts.Completions.Should().Contain("Updates"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/Fallout.Cli.Specs/ConfigurationReaderSpecs.cs b/tests/Fallout.Cli.Specs/ConfigurationReaderSpecs.cs new file mode 100644 index 000000000..4deb9a4af --- /dev/null +++ b/tests/Fallout.Cli.Specs/ConfigurationReaderSpecs.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using Fallout.Common.IO; +using FluentAssertions; +using Xunit; + +namespace Fallout.Cli.Specs; + +public class ConfigurationReaderSpecs +{ + private static AbsolutePath WriteScript(AbsolutePath dir) => + WriteScript(dir, string.Join("\n", + "# CONFIGURATION", + "##############", + "", + "BUILD_PROJECT_FILE=\"build/_build.csproj\"", + "TEMP_DIRECTORY=\"$SCRIPT_DIR/.fallout/temp\"", + "", + "# EXECUTION", + "dotnet run")); + + private static AbsolutePath WriteScript(AbsolutePath dir, string content) + { + var buildScript = dir / "build.sh"; + File.WriteAllText(buildScript, content); + return buildScript; + } + + [Fact] + public void Read_ParsesConfigurationEntries() + { + using var temp = TempDir.Create(); + var buildScript = WriteScript(temp.Path); + + var configuration = new ConfigurationReader().Read(buildScript, evaluate: false); + + configuration.Should().ContainKey(ConfigurationReader.BuildProjectFileKey) + .WhoseValue.Should().Be("build/_build.csproj"); + } + + [Fact] + public void Read_WhenEvaluating_ReplacesScriptDirectoryToken() + { + using var temp = TempDir.Create(); + var buildScript = WriteScript(temp.Path); + + var configuration = new ConfigurationReader().Read(buildScript, evaluate: true); + + configuration["TEMP_DIRECTORY"].Should().NotContain("$SCRIPT_DIR") + .And.Contain(buildScript.Parent); + } + + private sealed class TempDir : IDisposable + { + public AbsolutePath Path { get; } + + private TempDir(AbsolutePath path) => Path = path; + + public static TempDir Create() + { + var dir = (AbsolutePath)System.IO.Path.Combine(System.IO.Path.GetTempPath(), "fallout-cfgreader-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + return new TempDir(dir); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/Fallout.Cli.Specs/UpdateSolutionFileContentSpecs.cs b/tests/Fallout.Cli.Specs/UpdateSolutionFileContentSpecs.cs index 3bc159dca..46ef79926 100644 --- a/tests/Fallout.Cli.Specs/UpdateSolutionFileContentSpecs.cs +++ b/tests/Fallout.Cli.Specs/UpdateSolutionFileContentSpecs.cs @@ -68,7 +68,7 @@ public class UpdateSolutionFileContentSpecs public Task Test(int number, string input) { var content = input.SplitLineBreaks().ToList(); - Program.UpdateSolutionFileContent(content, "RELATIVE", "GUID", "NAME"); + new BuildScaffolder().UpdateSolutionFileContent(content, "RELATIVE", "GUID", "NAME"); return Verifier.Verify(string.Join(Environment.NewLine, content)) .UseParameters(number); @@ -85,7 +85,7 @@ public Task Test(int number, string input) public Task TestXml(int number, string input) { var content = XDocument.Load(new StringReader(input)); - Program.UpdateSolutionXmlFileContent(content, "RELATIVE"); + new BuildScaffolder().UpdateSolutionXmlFileContent(content, "RELATIVE"); var settings = new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true }; var stringStream = new StringWriter(); From e7b988eb8c7b78e93d5ec667b6ccf4be085ad0d5 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 15 Jul 2026 20:42:40 +1200 Subject: [PATCH 05/11] Make async commands genuinely async instead of blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddPackage, CakeConvert and Setup did real async work (NuGet version resolution, nested command invocation) but blocked on it via .GetAwaiter().GetResult() inside a synchronous Execute wrapped in Task.FromResult. Fold the private Execute into an async ExecuteAsync and await the work directly, removing the sync-over-async anti-pattern. Purely-sync commands keep Task.FromResult(Execute(...)) β€” they have no async work to await. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/AddPackageCommand.cs | 7 ++----- src/Fallout.Cli/Commands/CakeConvertCommand.cs | 7 ++----- src/Fallout.Cli/Commands/SetupCommand.cs | 9 +++------ 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/Fallout.Cli/Commands/AddPackageCommand.cs b/src/Fallout.Cli/Commands/AddPackageCommand.cs index 3fb4c1750..204c33848 100644 --- a/src/Fallout.Cli/Commands/AddPackageCommand.cs +++ b/src/Fallout.Cli/Commands/AddPackageCommand.cs @@ -25,10 +25,7 @@ public AddPackageCommand(IConfigurationReader configuration, IPackageManager pac public string Name => "add-package"; - public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - => Task.FromResult(Execute(args, rootDirectory, buildScript)); - - private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public async Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { ToolBanner.Print(); Logging.Configure(); @@ -39,7 +36,7 @@ private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buil var packageVersion = (EnvironmentInfo.GetNamedArgument("version") ?? args.ElementAtOrDefault(1) ?? - NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false).GetAwaiter().GetResult() ?? + await NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false) ?? NuGetPackageResolver.GetGlobalInstalledPackage(packageId, version: null, packagesConfigFile: null)?.Version.ToString()) .NotNull("packageVersion != null"); diff --git a/src/Fallout.Cli/Commands/CakeConvertCommand.cs b/src/Fallout.Cli/Commands/CakeConvertCommand.cs index 525a8b786..ea90297ed 100644 --- a/src/Fallout.Cli/Commands/CakeConvertCommand.cs +++ b/src/Fallout.Cli/Commands/CakeConvertCommand.cs @@ -35,10 +35,7 @@ public CakeConvertCommand( public string Name => "cake-convert"; - public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - => Task.FromResult(Execute(args, rootDirectory, buildScript)); - - private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public async Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { ToolBanner.Print(); Logging.Configure(); @@ -67,7 +64,7 @@ private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buil if (buildScript == null && _prompts.PromptForConfirmation("Should a NUKE project be created for better results?")) { - _setup.ExecuteAsync(args, rootDirectory: null, buildScript: null).GetAwaiter().GetResult(); + await _setup.ExecuteAsync(args, rootDirectory: null, buildScript: null); } var buildScriptFile = WorkingDirectory / CliConventions.CurrentBuildScriptName; diff --git a/src/Fallout.Cli/Commands/SetupCommand.cs b/src/Fallout.Cli/Commands/SetupCommand.cs index 0ebfe9d77..4c493ca95 100644 --- a/src/Fallout.Cli/Commands/SetupCommand.cs +++ b/src/Fallout.Cli/Commands/SetupCommand.cs @@ -38,10 +38,7 @@ public SetupCommand(IConsolePrompts prompts, IBuildScaffolder scaffolder) public string Name => "setup"; - public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - => Task.FromResult(Execute(args, rootDirectory, buildScript)); - - private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public async Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { ToolBanner.Print(); Logging.Configure(); @@ -79,8 +76,8 @@ private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buil var falloutVersion = _prompts.PromptForChoice("Which Fallout.Common version should be used?", new[] { - ("latest release", falloutLatestReleaseVersion.GetAwaiter().GetResult()), - ("latest prerelease", falloutLatestPrereleaseVersion.GetAwaiter().GetResult()), + ("latest release", await falloutLatestReleaseVersion), + ("latest prerelease", await falloutLatestPrereleaseVersion), ("latest local", falloutLatestLocalVersion), ("same as global tool", typeof(SetupCommand).GetTypeInfo().Assembly.GetVersionText()) } From 4159cd0565af5f47f1be9c09ffaa7f792fecf45b Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 15 Jul 2026 20:42:40 +1200 Subject: [PATCH 06/11] Rename Cli command specs to AV1600 behavior-focused style The new command specs used Method_State_Result names; the repo convention (docs/agents/conventions.md, AV1600) is short present-tense descriptions of observable behavior. Rename all command spec methods to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs | 4 ++-- tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs | 6 +++--- .../Commands/GetConfigurationCommandSpecs.cs | 4 ++-- tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs | 4 ++-- tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs | 4 ++-- tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs index b0666ab66..a06489b75 100644 --- a/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/CakeCleanCommandSpecs.cs @@ -8,11 +8,11 @@ namespace Fallout.Cli.Specs.Commands; public class CakeCleanCommandSpecs { [Fact] - public void Name_IsCakeClean() + public void Command_is_named_cake_clean() => new CakeCleanCommand(new FakeConsolePrompts()).Name.Should().Be("cake-clean"); [Fact] - public async Task Execute_WhenDeletionDeclined_ReturnsZeroAndDeletesNothing() + public async Task Declining_the_deletion_prompt_deletes_nothing_and_returns_zero() { // ConfirmationResult = false β†’ the "Delete?" prompt is declined, so no .cake files are removed. var prompts = new FakeConsolePrompts { ConfirmationResult = false }; diff --git a/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs index febd2d8ca..5f1119ea4 100644 --- a/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/CompleteCommandSpecs.cs @@ -11,15 +11,15 @@ namespace Fallout.Cli.Specs.Commands; public class CompleteCommandSpecs { [Fact] - public void Name_IsComplete() + public void Command_is_named_complete() => new CompleteCommand().Name.Should().Be("complete"); [Fact] - public async Task Execute_WithoutRootDirectory_ReturnsZero() + public async Task Completion_without_a_root_directory_returns_zero() => (await new CompleteCommand().ExecuteAsync(["fallout "], rootDirectory: null, buildScript: null)).Should().Be(0); [Fact] - public async Task Execute_WordNotStartingWithCommandName_ReturnsZero() + public async Task Completion_for_a_non_fallout_command_line_returns_zero() { var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-complete-" + Guid.NewGuid().ToString("N")); dir.CreateDirectory(); diff --git a/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs index 10331a353..186dda3fa 100644 --- a/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/GetConfigurationCommandSpecs.cs @@ -11,11 +11,11 @@ namespace Fallout.Cli.Specs.Commands; public class GetConfigurationCommandSpecs { [Fact] - public void Name_IsGetConfiguration() + public void Command_is_named_get_configuration() => new GetConfigurationCommand(new ConfigurationReader()).Name.Should().Be("get-configuration"); [Fact] - public async Task Execute_ParsesConfigurationBlock_ReturnsZero() + public async Task Parsing_a_configuration_block_returns_zero() { var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-getcfg-" + Guid.NewGuid().ToString("N")); dir.CreateDirectory(); diff --git a/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs index 13bb53d2a..a6227255d 100644 --- a/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/SecretsCommandSpecs.cs @@ -9,11 +9,11 @@ namespace Fallout.Cli.Specs.Commands; public class SecretsCommandSpecs { [Fact] - public void Name_IsSecrets() + public void Command_is_named_secrets() => new SecretsCommand(new FakeConsolePrompts()).Name.Should().Be("secrets"); [Fact] - public async Task Execute_WithoutRootDirectory_Throws() + public async Task Running_without_a_root_directory_throws() { var action = async () => await new SecretsCommand(new FakeConsolePrompts()) .ExecuteAsync([], rootDirectory: null, buildScript: null); diff --git a/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs index 665ad8a91..7788f3421 100644 --- a/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/TriggerCommandSpecs.cs @@ -11,11 +11,11 @@ namespace Fallout.Cli.Specs.Commands; public class TriggerCommandSpecs { [Fact] - public void Name_IsTrigger() + public void Command_is_named_trigger() => new TriggerCommand().Name.Should().Be("trigger"); [Fact] - public async Task Execute_OutsideGitRepository_Throws() + public async Task Running_outside_a_git_repository_throws() { var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-trigger-" + Guid.NewGuid().ToString("N")); dir.CreateDirectory(); diff --git a/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs index 274c10b98..2de72c02a 100644 --- a/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs @@ -11,11 +11,11 @@ namespace Fallout.Cli.Specs.Commands; public class UpdateCommandSpecs { [Fact] - public void Name_IsUpdate() + public void Command_is_named_update() => new UpdateCommand(new FakeConsolePrompts(), new ConfigurationReader(), new BuildScaffolder()).Name.Should().Be("update"); [Fact] - public async Task Execute_NoBuildScript_DeclineAll_ReturnsZeroAndReportsCompletion() + public async Task Declining_all_updates_without_a_build_script_completes_and_returns_zero() { var dir = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-update-" + Guid.NewGuid().ToString("N")); dir.CreateDirectory(); From bbf0dfee2206918ad432c6ed3421af2b8510a8ca Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 15 Jul 2026 21:03:36 +1200 Subject: [PATCH 07/11] Thread async through the update command's build-project step The last sync-over-async in the command layer sat in ProjectUpdater, reached from UpdateCommand via the synchronous ConfirmExecution(Action) prompt boundary. ConfirmExecution is a hand-rolled helper (not a Spectre primitive), so add a Func overload alongside the Action one and flow async through: - IConsolePrompts.ConfirmExecutionAsync + SpectreConsolePrompts impl - ProjectUpdater.Update -> async UpdateAsync (out param becomes a return) - UpdateCommand.UpdateBuildProject -> UpdateBuildProjectAsync, folded into a genuine async ExecuteAsync The genuinely-sync update steps (build scripts, config file, global.json) stay on the sync ConfirmExecution overload. No .GetAwaiter().GetResult() remains in the command layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/UpdateCommand.cs | 11 +++---- src/Fallout.Cli/ProjectUpdater.cs | 17 ++++++----- src/Fallout.Cli/Prompts/IConsolePrompts.cs | 7 +++++ .../Prompts/SpectreConsolePrompts.cs | 30 +++++++++++++++++++ .../CommandDispatcherSpecs.cs | 1 + .../Commands/FakeConsolePrompts.cs | 7 +++++ 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/Fallout.Cli/Commands/UpdateCommand.cs b/src/Fallout.Cli/Commands/UpdateCommand.cs index 310086c00..6bd7ae1c1 100644 --- a/src/Fallout.Cli/Commands/UpdateCommand.cs +++ b/src/Fallout.Cli/Commands/UpdateCommand.cs @@ -31,10 +31,7 @@ public UpdateCommand(IConsolePrompts prompts, IConfigurationReader configuration public string Name => "update"; - public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - => Task.FromResult(Execute(args, rootDirectory, buildScript)); - - private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + public async Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) { ToolBanner.Print(); Logging.Configure(); @@ -44,7 +41,7 @@ private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buil if (buildScript != null) { _prompts.ConfirmExecution("Update build scripts", () => UpdateBuildScripts(rootDirectory, buildScript)); - _prompts.ConfirmExecution("Update build project", () => UpdateBuildProject(buildScript)); + await _prompts.ConfirmExecutionAsync("Update build project", () => UpdateBuildProjectAsync(buildScript)); } _prompts.ConfirmExecution("Update configuration file", () => UpdateConfigurationFile(rootDirectory)); @@ -62,12 +59,12 @@ private void UpdateBuildScripts(AbsolutePath rootDirectory, AbsolutePath buildSc rootDirectory); } - private void UpdateBuildProject(AbsolutePath buildScript) + private async Task UpdateBuildProjectAsync(AbsolutePath buildScript) { var configuration = _configuration.Read(buildScript, evaluate: true); var projectFile = configuration[ConfigurationReader.BuildProjectFileKey]; ProjectModelTasks.Initialize(); - ProjectUpdater.Update(projectFile); + await ProjectUpdater.UpdateAsync(projectFile); } private void UpdateConfigurationFile(AbsolutePath rootDirectory) diff --git a/src/Fallout.Cli/ProjectUpdater.cs b/src/Fallout.Cli/ProjectUpdater.cs index 685ac323f..9040f7e9b 100644 --- a/src/Fallout.Cli/ProjectUpdater.cs +++ b/src/Fallout.Cli/ProjectUpdater.cs @@ -1,5 +1,6 @@ ο»Ώusing System; using System.Linq; +using System.Threading.Tasks; using NuGet.Versioning; using Fallout.Common; using Fallout.Solutions; @@ -10,12 +11,12 @@ namespace Fallout.Cli; public static class ProjectUpdater { - public static void Update(string projectFile) + public static async Task UpdateAsync(string projectFile) { var buildProject = ProjectModelTasks.ParseProject(projectFile).NotNull(); UpdateTargetFramework(buildProject); - UpdateNukeCommonPackage(buildProject, out var previousPackageVersion); + var previousPackageVersion = await UpdateNukeCommonPackageAsync(buildProject); if (previousPackageVersion.MinVersion >= NuGetVersion.Parse("0.23.5")) RemoveLegacyFileIncludes(buildProject); @@ -28,16 +29,16 @@ private static void UpdateTargetFramework(Microsoft.Build.Evaluation.Project bui buildProject.SetProperty("TargetFramework", "net8.0"); } - private static void UpdateNukeCommonPackage(Microsoft.Build.Evaluation.Project buildProject, out FloatRange previousPackageVersion) + private static async Task UpdateNukeCommonPackageAsync(Microsoft.Build.Evaluation.Project buildProject) { var packageItem = buildProject.Items.SingleOrDefault(x => x.EvaluatedInclude == Constants.FalloutCommonPackageId).NotNull(); - previousPackageVersion = FloatRange.Parse(packageItem.GetMetadataValue("Version")); + var previousPackageVersion = FloatRange.Parse(packageItem.GetMetadataValue("Version")); - var latestPackageVersion = NuGetVersionResolver.GetLatestVersion(Constants.FalloutCommonPackageId, includePrereleases: false).GetAwaiter().GetResult(); - if (previousPackageVersion.Satisfies(NuGetVersion.Parse(latestPackageVersion))) - return; + var latestPackageVersion = await NuGetVersionResolver.GetLatestVersion(Constants.FalloutCommonPackageId, includePrereleases: false); + if (!previousPackageVersion.Satisfies(NuGetVersion.Parse(latestPackageVersion))) + packageItem.SetMetadataValue("Version", latestPackageVersion); - packageItem.SetMetadataValue("Version", latestPackageVersion); + return previousPackageVersion; } private static void RemoveLegacyFileIncludes(Microsoft.Build.Evaluation.Project buildProject) diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs index 99b383b9b..f0f3ad496 100644 --- a/src/Fallout.Cli/Prompts/IConsolePrompts.cs +++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; namespace Fallout.Cli.Prompts; @@ -36,4 +37,10 @@ internal interface IConsolePrompts /// reporting success or failure without throwing. /// void ConfirmExecution(string title, Action action); + + /// + /// Asynchronous counterpart of : awaits the confirmed + /// work instead of running it synchronously, so commands with genuinely async steps don't block. + /// + Task ConfirmExecutionAsync(string title, Func action); } diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs index 8013f2105..f57737562 100644 --- a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs +++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.Utilities; using Spectre.Console; @@ -92,4 +93,33 @@ public void ConfirmExecution(string title, Action action) var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); } + + public async Task ConfirmExecutionAsync(string title, Func action) + { + Assert.False(title.EndsWith('?')); + + var confirmation = PromptForConfirmation($"{title}?"); + ClearPreviousLine(); + + if (confirmation) + { + AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); + try + { + await action(); + } + 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 index 7bb97405c..cc1d38456 100644 --- a/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs +++ b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs @@ -181,6 +181,7 @@ public void ClearPreviousLine() { } 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(); + public Task ConfirmExecutionAsync(string title, Func action) => action(); } private sealed class TempRoot : IDisposable diff --git a/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs b/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs index ecdd13d31..ea5855910 100644 --- a/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs +++ b/tests/Fallout.Cli.Specs/Commands/FakeConsolePrompts.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Fallout.Cli.Prompts; namespace Fallout.Cli.Specs.Commands; @@ -32,4 +33,10 @@ public void ConfirmExecution(string title, Action action) if (InvokeConfirmedActions) action(); } + + public async Task ConfirmExecutionAsync(string title, Func action) + { + if (InvokeConfirmedActions) + await action(); + } } From d53d7410763f564e4be855faf261f6d8ebe28bfa Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 15 Jul 2026 21:15:50 +1200 Subject: [PATCH 08/11] Internalize Fallout.Cli types that aren't external contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migrated commands are all `internal sealed`, but three types were still `public` with no consumer outside the assembly and no PublicAPI tracking: RunCommand (the lone public command), ProjectUpdater, and Program (the tool entry point). None are part of a public surface β€” there is no public command SDK yet (that's milestone #7). Make them internal to match the rest of the command set. Fallout.Cli.Specs already has InternalsVisibleTo, so the specs are unaffected; DI registration works unchanged with internal types. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/RunCommand.cs | 2 +- src/Fallout.Cli/Program.cs | 2 +- src/Fallout.Cli/ProjectUpdater.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Fallout.Cli/Commands/RunCommand.cs b/src/Fallout.Cli/Commands/RunCommand.cs index dd6b66218..a98d93fa8 100644 --- a/src/Fallout.Cli/Commands/RunCommand.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -15,7 +15,7 @@ namespace Fallout.Cli.Commands; /// 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 +internal sealed class RunCommand : IFalloutCommand { public string Name => "run"; diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 83108f2ea..11091ba7c 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -13,7 +13,7 @@ namespace Fallout.Cli; -public partial class Program +internal partial class Program { private static async Task Main(string[] args) { diff --git a/src/Fallout.Cli/ProjectUpdater.cs b/src/Fallout.Cli/ProjectUpdater.cs index 9040f7e9b..ee0ab13cd 100644 --- a/src/Fallout.Cli/ProjectUpdater.cs +++ b/src/Fallout.Cli/ProjectUpdater.cs @@ -9,7 +9,7 @@ namespace Fallout.Cli; -public static class ProjectUpdater +internal static class ProjectUpdater { public static async Task UpdateAsync(string projectFile) { From 4603042d2cbf7c500fc31d94cd98381cdca2cd49 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 15 Jul 2026 21:42:08 +1200 Subject: [PATCH 09/11] Fold ProjectUpdater into UpdateCommand The build-project update logic lived in a separate static ProjectUpdater helper, splitting the update command's behaviour across two files for no reason. Move it into UpdateCommand as private methods so the command owns its own logic, and delete ProjectUpdater. Also drops the stale "Nuke" name (UpdateNukeCommonPackage -> UpdateFalloutCommonPackage). No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/UpdateCommand.cs | 61 ++++++++++++++++++- src/Fallout.Cli/ProjectUpdater.cs | 74 ----------------------- 2 files changed, 60 insertions(+), 75 deletions(-) delete mode 100644 src/Fallout.Cli/ProjectUpdater.cs diff --git a/src/Fallout.Cli/Commands/UpdateCommand.cs b/src/Fallout.Cli/Commands/UpdateCommand.cs index 6bd7ae1c1..6ad6580af 100644 --- a/src/Fallout.Cli/Commands/UpdateCommand.cs +++ b/src/Fallout.Cli/Commands/UpdateCommand.cs @@ -2,11 +2,13 @@ using System.Threading.Tasks; using System.Linq; using System.Text.Json.Nodes; +using NuGet.Versioning; using Fallout.Cli.Prompts; using Fallout.Common; using Fallout.Common.Execution; using Fallout.Common.IO; using Fallout.Solutions; +using Fallout.Common.Tooling; using Fallout.Common.Tools.DotNet; using Fallout.Common.Utilities; using static Fallout.Common.Constants; @@ -63,8 +65,65 @@ private async Task UpdateBuildProjectAsync(AbsolutePath buildScript) { var configuration = _configuration.Read(buildScript, evaluate: true); var projectFile = configuration[ConfigurationReader.BuildProjectFileKey]; + ProjectModelTasks.Initialize(); - await ProjectUpdater.UpdateAsync(projectFile); + var buildProject = ProjectModelTasks.ParseProject(projectFile).NotNull(); + + UpdateTargetFramework(buildProject); + var previousPackageVersion = await UpdateFalloutCommonPackageAsync(buildProject); + + if (previousPackageVersion.MinVersion >= NuGetVersion.Parse("0.23.5")) + RemoveLegacyFileIncludes(buildProject); + + buildProject.Save(); + } + + private static void UpdateTargetFramework(Microsoft.Build.Evaluation.Project buildProject) + { + buildProject.SetProperty("TargetFramework", "net8.0"); + } + + private static async Task UpdateFalloutCommonPackageAsync(Microsoft.Build.Evaluation.Project buildProject) + { + var packageItem = buildProject.Items.SingleOrDefault(x => x.EvaluatedInclude == FalloutCommonPackageId).NotNull(); + var previousPackageVersion = FloatRange.Parse(packageItem.GetMetadataValue("Version")); + + var latestPackageVersion = await NuGetVersionResolver.GetLatestVersion(FalloutCommonPackageId, includePrereleases: false); + if (!previousPackageVersion.Satisfies(NuGetVersion.Parse(latestPackageVersion))) + packageItem.SetMetadataValue("Version", latestPackageVersion); + + return previousPackageVersion; + } + + private static void RemoveLegacyFileIncludes(Microsoft.Build.Evaluation.Project buildProject) + { + var legacyIncludes = + new[] + { + "csproj.DotSettings", + "build.ps1", + "build.sh", + ".nuke", + "global.json", + "nuget.config", + "azure-pipelines.yml", + "Jenkinsfile", + "appveyor.yml", + ".travis.yml", + "GitVersion.yml" + }; + + buildProject.Xml.Items + .Where(x => x.ItemType == "None").ToList() + .Where(x => x.Include.ContainsAnyOrdinalIgnoreCase(legacyIncludes) || + x.Remove.ContainsAnyOrdinalIgnoreCase(legacyIncludes)).ToList() + .ForEach(x => + { + var itemGroupElement = x.Parent; + itemGroupElement.RemoveChild(x); + if (itemGroupElement.Children.Count == 0) + itemGroupElement.Parent.RemoveChild(itemGroupElement); + }); } private void UpdateConfigurationFile(AbsolutePath rootDirectory) diff --git a/src/Fallout.Cli/ProjectUpdater.cs b/src/Fallout.Cli/ProjectUpdater.cs deleted file mode 100644 index ee0ab13cd..000000000 --- a/src/Fallout.Cli/ProjectUpdater.cs +++ /dev/null @@ -1,74 +0,0 @@ -ο»Ώusing System; -using System.Linq; -using System.Threading.Tasks; -using NuGet.Versioning; -using Fallout.Common; -using Fallout.Solutions; -using Fallout.Common.Tooling; -using Fallout.Common.Utilities; - -namespace Fallout.Cli; - -internal static class ProjectUpdater -{ - public static async Task UpdateAsync(string projectFile) - { - var buildProject = ProjectModelTasks.ParseProject(projectFile).NotNull(); - - UpdateTargetFramework(buildProject); - var previousPackageVersion = await UpdateNukeCommonPackageAsync(buildProject); - - if (previousPackageVersion.MinVersion >= NuGetVersion.Parse("0.23.5")) - RemoveLegacyFileIncludes(buildProject); - - buildProject.Save(); - } - - private static void UpdateTargetFramework(Microsoft.Build.Evaluation.Project buildProject) - { - buildProject.SetProperty("TargetFramework", "net8.0"); - } - - private static async Task UpdateNukeCommonPackageAsync(Microsoft.Build.Evaluation.Project buildProject) - { - var packageItem = buildProject.Items.SingleOrDefault(x => x.EvaluatedInclude == Constants.FalloutCommonPackageId).NotNull(); - var previousPackageVersion = FloatRange.Parse(packageItem.GetMetadataValue("Version")); - - var latestPackageVersion = await NuGetVersionResolver.GetLatestVersion(Constants.FalloutCommonPackageId, includePrereleases: false); - if (!previousPackageVersion.Satisfies(NuGetVersion.Parse(latestPackageVersion))) - packageItem.SetMetadataValue("Version", latestPackageVersion); - - return previousPackageVersion; - } - - private static void RemoveLegacyFileIncludes(Microsoft.Build.Evaluation.Project buildProject) - { - var legacyIncludes = - new[] - { - "csproj.DotSettings", - "build.ps1", - "build.sh", - ".nuke", - "global.json", - "nuget.config", - "azure-pipelines.yml", - "Jenkinsfile", - "appveyor.yml", - ".travis.yml", - "GitVersion.yml" - }; - - buildProject.Xml.Items - .Where(x => x.ItemType == "None").ToList() - .Where(x => x.Include.ContainsAnyOrdinalIgnoreCase(legacyIncludes) || - x.Remove.ContainsAnyOrdinalIgnoreCase(legacyIncludes)).ToList() - .ForEach(x => - { - var itemGroupElement = x.Parent; - itemGroupElement.RemoveChild(x); - if (itemGroupElement.Children.Count == 0) - itemGroupElement.Parent.RemoveChild(itemGroupElement); - }); - } -} From 047cdff61aeed6908e1f8b56e8b2527b3e40e2ad Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 18 Jul 2026 23:44:46 +1200 Subject: [PATCH 10/11] Fix update command writing net8.0 as the build project target framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The :update command's build-project step reset TargetFramework to net8.0, which can't restore Fallout.Common or resolve its namespaces β€” the framework targets net10.0. SetupCommand already scaffolds new projects at net10.0 (TARGET_FRAMEWORK), so update now matches. Centralizing the target-framework literal (SetupCommand + UpdateCommand share "net10.0") is deferred to a dedicated cleanup PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/UpdateCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fallout.Cli/Commands/UpdateCommand.cs b/src/Fallout.Cli/Commands/UpdateCommand.cs index 6ad6580af..d71745cd0 100644 --- a/src/Fallout.Cli/Commands/UpdateCommand.cs +++ b/src/Fallout.Cli/Commands/UpdateCommand.cs @@ -80,7 +80,7 @@ private async Task UpdateBuildProjectAsync(AbsolutePath buildScript) private static void UpdateTargetFramework(Microsoft.Build.Evaluation.Project buildProject) { - buildProject.SetProperty("TargetFramework", "net8.0"); + buildProject.SetProperty("TargetFramework", "net10.0"); } private static async Task UpdateFalloutCommonPackageAsync(Microsoft.Build.Evaluation.Project buildProject) From 75499c57df19d5d432c31c3f1859c0983d55f757 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 18 Jul 2026 23:50:57 +1200 Subject: [PATCH 11/11] Cover the update command's target-framework retarget The net8.0 bug slipped through because the build-project update path had no spec. Add one: parse a project pinned to an old TargetFramework, run the retarget, assert it lands on net10.0. Guards the literal from drifting again. UpdateTargetFramework is now internal (Fallout.Cli.Specs already has IVT) and Cli.Specs gets the ProjectModel MSBuild-resolver ModuleInit so a test can hold a Microsoft.Build.Evaluation.Project local. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Commands/UpdateCommand.cs | 2 +- .../Commands/UpdateCommandSpecs.cs | 31 +++++++++++++++++++ tests/Fallout.Cli.Specs/ModuleInit.cs | 19 ++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/Fallout.Cli.Specs/ModuleInit.cs diff --git a/src/Fallout.Cli/Commands/UpdateCommand.cs b/src/Fallout.Cli/Commands/UpdateCommand.cs index d71745cd0..3db68cd6e 100644 --- a/src/Fallout.Cli/Commands/UpdateCommand.cs +++ b/src/Fallout.Cli/Commands/UpdateCommand.cs @@ -78,7 +78,7 @@ private async Task UpdateBuildProjectAsync(AbsolutePath buildScript) buildProject.Save(); } - private static void UpdateTargetFramework(Microsoft.Build.Evaluation.Project buildProject) + internal static void UpdateTargetFramework(Microsoft.Build.Evaluation.Project buildProject) { buildProject.SetProperty("TargetFramework", "net10.0"); } diff --git a/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs b/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs index 2de72c02a..d57cbaed9 100644 --- a/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs +++ b/tests/Fallout.Cli.Specs/Commands/UpdateCommandSpecs.cs @@ -2,6 +2,7 @@ using System.IO; using Fallout.Cli.Commands; using Fallout.Common.IO; +using Fallout.Solutions; using FluentAssertions; using System.Threading.Tasks; using Xunit; @@ -14,6 +15,36 @@ public class UpdateCommandSpecs public void Command_is_named_update() => new UpdateCommand(new FakeConsolePrompts(), new ConfigurationReader(), new BuildScaffolder()).Name.Should().Be("update"); + [Fact] + public void Updating_the_build_project_retargets_it_to_net10() + { + // A previous Fallout could leave a stale ; update must lift it to the + // framework Fallout.Common ships for. Guards against the literal drifting off net10.0. + var projectFile = (AbsolutePath)Path.Combine( + Path.GetTempPath(), "fallout-update-" + Guid.NewGuid().ToString("N"), "_build.csproj"); + projectFile.Parent.CreateDirectory(); + projectFile.WriteAllText( + """ + + + net8.0 + + + """); + try + { + var buildProject = ProjectModelTasks.ParseProject(projectFile); + + UpdateCommand.UpdateTargetFramework(buildProject); + + buildProject.GetPropertyValue("TargetFramework").Should().Be("net10.0"); + } + finally + { + Directory.Delete(projectFile.Parent, recursive: true); + } + } + [Fact] public async Task Declining_all_updates_without_a_build_script_completes_and_returns_zero() { diff --git a/tests/Fallout.Cli.Specs/ModuleInit.cs b/tests/Fallout.Cli.Specs/ModuleInit.cs new file mode 100644 index 000000000..afdece7d4 --- /dev/null +++ b/tests/Fallout.Cli.Specs/ModuleInit.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; +using Fallout.Solutions; + +namespace Fallout.Cli.Specs; + +internal static class ModuleInit +{ + // Microsoft.Build is excluded from runtime output (ExcludeAssets="runtime") and resolved + // at runtime via the AssemblyResolve handler that ProjectModelTasks installs from its own + // [ModuleInitializer]. If a test method declares a local of a Microsoft.Build type, the + // JIT resolves Microsoft.Build.dll before any Fallout.ProjectModel code runs β€” and the + // resolver is not yet installed. Touching ProjectModelTasks here forces Fallout.ProjectModel + // to load (and its module initializer to fire) before xUnit JITs the first test. + [ModuleInitializer] + public static void EnsureMSBuildResolverRegistered() + { + ProjectModelTasks.Initialize(); + } +}