From d97b4098ffbcd39767d0c3e2cd66d2b7bb42647b Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:12:55 +1200 Subject: [PATCH 1/4] 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 7f01bd58be890b2a2fa4efbf5c2d1f57f6aedcca Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:41:51 +1200 Subject: [PATCH 2/4] 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 7deba9c3ff8bdb76d34b436075702b489be913d2 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:41:51 +1200 Subject: [PATCH 3/4] 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 4abf66f196acd717692079f0221021b8acb4008a Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 11 Jul 2026 22:41:51 +1200 Subject: [PATCH 4/4] 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();