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/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/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..ed2c2f595 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.
+///
+internal 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/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/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();
+ }
+}
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 2221f1d9a..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();
-
- // Legacy handlers still living on Program, adapted until they are extracted into command
- // types. Each conversion deletes one line here plus its Program.X.cs partial.
- services.AddSingleton(new DelegateCommand("setup", Setup));
- services.AddSingleton(new DelegateCommand("update", Update));
- services.AddSingleton(new DelegateCommand("add-package", AddPackage));
- services.AddSingleton(new DelegateCommand("cake-convert", CakeConvert));
- services.AddSingleton(new DelegateCommand("cake-clean", CakeClean));
- services.AddSingleton(new DelegateCommand("complete", Complete));
- services.AddSingleton(new DelegateCommand("get-configuration", GetConfiguration));
- services.AddSingleton(new DelegateCommand("secrets", Secrets));
- services.AddSingleton(new DelegateCommand("trigger", Trigger));
- services.AddSingleton(new DelegateCommand("GetNextDirectory", (_, _, _) => GetNextDirectory()));
- services.AddSingleton(new DelegateCommand("PopDirectory", (_, _, _) => PopDirectory()));
- services.AddSingleton(new DelegateCommand("PushWithCurrentRootDirectory", (_, rootDirectory, _) => PushWithCurrentRootDirectory(rootDirectory)));
- services.AddSingleton(new DelegateCommand("PushWithParentRootDirectory", (_, rootDirectory, _) => PushWithParentRootDirectory(rootDirectory)));
- services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", (_, _, _) => PushWithChosenRootDirectory()));
- }
-
- 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();
+ 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);
}
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