diff --git a/.vscode/launch.json b/.vscode/launch.json index 47f3a49..92ba1f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,8 @@ "args": ["i"], "cwd": "${workspaceFolder}", "console": "externalTerminal", - "stopAtEntry": false + "stopAtEntry": false, + "brokeredServicePipeName": "undefined", } ] } \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 80b9f53..3a94d5e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,15 @@ - Kentico - Eric Dugre - 4.0.0 - Xperience by Kentico - 12.0 - enable - enable - + Kentico + Eric Dugre + 5.0.0 + Xperience by Kentico + 12.0 + enable + enable + https://github.com/Kentico/xperience-by-kentico-manager + git + @@ -19,4 +21,4 @@ http://timestamp.digicert.com - \ No newline at end of file + diff --git a/README.md b/README.md index 9c938e4..32ba280 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The following commands can be executed using the `xman` tool name: - `?`, `help` - [`i`, `install`](#installing-a-new-project) - [`u`, `update`](#updating-a-project-version) +- [`d`, `delete`](#deleting-a-project) - [`m`, `macros`](#re-signing-macros) - [`b`, `build`](#building-projects) - [`g`, `generate`](#generating-code-for-object-types) @@ -103,6 +104,8 @@ The installation wizard will automatically generate an administrator password fo xman install ``` +Installing a new project automatically includes a database as well. If you want to _only_ install a database and not the project files, use the __db__ parameter: `xman install db`. + ### Updating a project version 1. (optional) Select a profile with the [`profile`](#managing-profiles) command @@ -112,6 +115,17 @@ The installation wizard will automatically generate an administrator password fo xman update ``` +### Deleting a project + +> :warning: The `delete` command will drop the database and delete the files. Use with caution! + +1. (optional) Select a profile with the [`profile`](#managing-profiles) command +1. Run the `delete` command from the directory containing the [configuration file](#configuration-file): + + ```bash + xman delete + ``` + ### Modifying appsettings.json This tool can assist with changing the _CMSConnectionString_, supported [configuration keys](https://docs.xperience.io/xp/developers-and-admins/configuration/reference-configuration-keys), and the [headless API](https://docs.xperience.io/xp/developers-and-admins/configuration/headless-channel-management#Headlesschannelmanagement-ConfiguretheheadlessAPI). diff --git a/img/icon.png b/img/icon.png new file mode 100644 index 0000000..046463d Binary files /dev/null and b/img/icon.png differ diff --git a/src/Commands/Base/AbstractCommand.cs b/src/Commands/Base/AbstractCommand.cs index 2c9f73c..4ebda89 100644 --- a/src/Commands/Base/AbstractCommand.cs +++ b/src/Commands/Base/AbstractCommand.cs @@ -54,7 +54,7 @@ public virtual Task PreExecute(ToolProfile? profile, string? action) /// - /// A handler which can be assigned to to handler errors. + /// A handler which can be assigned to to handle errors. /// protected void ErrorDataReceived(object sender, DataReceivedEventArgs e) { @@ -80,7 +80,7 @@ protected void LogError(string message, Process? process = null) } - protected void PrintCurrentProfile(ToolProfile? profile) + protected static void PrintCurrentProfile(ToolProfile? profile) { AnsiConsole.Write(new Rule("Current profile:") { Justification = Justify.Left }); AnsiConsole.MarkupLineInterpolated($"Name: [{Constants.EMPHASIS_COLOR}]{profile?.ProjectName ?? "None"}[/]"); diff --git a/src/Commands/BuildCommand.cs b/src/Commands/BuildCommand.cs index d7ceda9..a01ebba 100644 --- a/src/Commands/BuildCommand.cs +++ b/src/Commands/BuildCommand.cs @@ -17,7 +17,7 @@ public class BuildCommand : AbstractCommand public override IEnumerable Keywords => ["b", "build"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => []; public override string Description => "Builds a project"; diff --git a/src/Commands/CodeGenerateCommand.cs b/src/Commands/CodeGenerateCommand.cs index 892b655..98a424c 100644 --- a/src/Commands/CodeGenerateCommand.cs +++ b/src/Commands/CodeGenerateCommand.cs @@ -20,7 +20,7 @@ public class CodeGenerateCommand : AbstractCommand public override IEnumerable Keywords => ["g", "generate"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => []; public override string Description => "Generates code files for Xperience objects"; diff --git a/src/Commands/DeleteCommand.cs b/src/Commands/DeleteCommand.cs new file mode 100644 index 0000000..7e58cd0 --- /dev/null +++ b/src/Commands/DeleteCommand.cs @@ -0,0 +1,145 @@ +using Spectre.Console; + +using Xperience.Manager.Configuration; +using Xperience.Manager.Options; +using Xperience.Manager.Services; + +namespace Xperience.Manager.Commands +{ + /// + /// A command which deletes an Xperience by Kentico project. + /// + public class DeleteCommand : AbstractCommand + { + private bool deleteConfirmed; + private readonly IShellRunner shellRunner; + private readonly IScriptBuilder scriptBuilder; + private readonly IConfigManager configManager; + private readonly IAppSettingsManager appSettingsManager; + + + public override IEnumerable Keywords => ["d", "delete"]; + + + public override IEnumerable Parameters => []; + + + public override string Description => "Deletes a project and its database"; + + + public override bool RequiresProfile => true; + + + /// + /// Do not use. Workaround for circular dependency in when commands are injected + /// into the constuctor. + /// +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + internal DeleteCommand() +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + } + + + public DeleteCommand(IShellRunner shellRunner, IScriptBuilder scriptBuilder, IConfigManager configManager, IAppSettingsManager appSettingsManager) + { + this.shellRunner = shellRunner; + this.scriptBuilder = scriptBuilder; + this.configManager = configManager; + this.appSettingsManager = appSettingsManager; + } + + + public override async Task Execute(ToolProfile? profile, string? action) + { + deleteConfirmed = AnsiConsole.Confirm($"This will [{Constants.ERROR_COLOR}]delete[/] the current profile's physical folder and database!\nDo you want to continue?", false); + if (!deleteConfirmed) + { + return; + } + + AnsiConsole.WriteLine(); + await DropDatabase(profile); + await UninstallFiles(profile); + if (!StopProcessing) + { + await configManager.RemoveProfile(profile); + } + } + + + public override async Task PostExecute(ToolProfile? profile, string? action) + { + if (!deleteConfirmed) + { + AnsiConsole.MarkupLineInterpolated($"[{Constants.EMPHASIS_COLOR}]Delete cancelled[/]\n"); + } + else if (!Errors.Any()) + { + AnsiConsole.MarkupLineInterpolated($"[{Constants.SUCCESS_COLOR}]Delete complete![/]\n"); + } + + await base.PostExecute(profile, action); + } + + + private async Task DropDatabase(ToolProfile? profile) + { + if (StopProcessing) + { + return; + } + + AnsiConsole.MarkupLineInterpolated($"[{Constants.EMPHASIS_COLOR}]Deleting database...[/]"); + + string? connString = await appSettingsManager.GetConnectionString(profile, "CMSConnectionString"); + if (connString is null) + { + LogError("Couldn't load connection string."); + return; + } + + // Find "Initial Catalog" in connection string + IEnumerable parts = connString.Split(';').ToList(); + string? initialCatalogPart = parts.FirstOrDefault(p => p.ToLower().StartsWith("initial catalog")); + if (initialCatalogPart is null) + { + LogError("Couldn't find database name."); + return; + } + + // Remove "Initial Catalog" from connection string, or trying to delete will throw "in use" error + parts = parts.Where(p => !p.Equals(initialCatalogPart, StringComparison.OrdinalIgnoreCase)); + connString = string.Join(';', parts); + string databaseName = initialCatalogPart.Split('=')[1].Trim(); + + var options = new RunSqlOptions() + { + SqlQuery = $"DROP DATABASE {databaseName}", + ConnString = connString + }; + string dbScript = scriptBuilder.SetScript(ScriptType.ExecuteSql).WithPlaceholders(options).Build(); + await shellRunner.Execute(new(dbScript) + { + ErrorHandler = ErrorDataReceived + }).WaitForExitAsync(); + } + + + private async Task UninstallFiles(ToolProfile? profile) + { + if (StopProcessing) + { + return; + } + + AnsiConsole.MarkupLineInterpolated($"[{Constants.EMPHASIS_COLOR}]Deleting local files...[/]"); + + string uninstallScript = scriptBuilder.SetScript(ScriptType.DeleteDirectory).WithPlaceholders(profile).Build(); + await shellRunner.Execute(new(uninstallScript) + { + ErrorHandler = ErrorDataReceived + }).WaitForExitAsync(); + } + } +} diff --git a/src/Commands/HelpCommand.cs b/src/Commands/HelpCommand.cs index 7a20fc5..691f1ea 100644 --- a/src/Commands/HelpCommand.cs +++ b/src/Commands/HelpCommand.cs @@ -19,6 +19,7 @@ public class HelpCommand : AbstractCommand [ new ProfileCommand(), new InstallCommand(), + new DeleteCommand(), new UpdateCommand(), new ContinuousIntegrationCommand(), new ContinuousDeploymentCommand(), @@ -32,7 +33,7 @@ public class HelpCommand : AbstractCommand public override IEnumerable Keywords => ["?", "help"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => []; public override string Description => "Displays the help menu (this screen)"; @@ -51,7 +52,7 @@ public override async Task Execute(ToolProfile? profile, string? action) AnsiConsole.WriteLine($" v{v.Major}.{v.Minor}.{v.Build}"); } - AnsiConsole.MarkupInterpolated($" [{Constants.EMPHASIS_COLOR}]https://github.com/kentico/xperience-manager[/]\n"); + AnsiConsole.MarkupInterpolated($" [{Constants.EMPHASIS_COLOR}]https://github.com/Kentico/xperience-by-kentico-manager[/]\n"); var table = new Table() .AddColumn("Command") diff --git a/src/Commands/InstallCommand.cs b/src/Commands/InstallCommand.cs index bcd05e6..3e7e108 100644 --- a/src/Commands/InstallCommand.cs +++ b/src/Commands/InstallCommand.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Xperience.Manager.Configuration; +using Xperience.Manager.Helpers; using Xperience.Manager.Options; using Xperience.Manager.Services; using Xperience.Manager.Wizards; @@ -14,20 +15,22 @@ namespace Xperience.Manager.Commands /// public class InstallCommand : AbstractCommand { + private const string DATABASE = "db"; private readonly ToolProfile newInstallationProfile = new(); private readonly IShellRunner shellRunner; private readonly IConfigManager configManager; private readonly IScriptBuilder scriptBuilder; - private readonly IWizard wizard; + private readonly IWizard projectWizard; + private readonly IWizard dbWizard; public override IEnumerable Keywords => ["i", "install"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => [DATABASE]; - public override string Description => "Installs a new XbK instance"; + public override string Description => "Installs a new XbK instance. The 'db' parameter installs only a database"; /// @@ -41,9 +44,15 @@ internal InstallCommand() } - public InstallCommand(IShellRunner shellRunner, IScriptBuilder scriptBuilder, IWizard wizard, IConfigManager configManager) + public InstallCommand( + IShellRunner shellRunner, + IScriptBuilder scriptBuilder, + IWizard projectWizard, + IWizard dbWizard, + IConfigManager configManager) { - this.wizard = wizard; + this.dbWizard = dbWizard; + this.projectWizard = projectWizard; this.shellRunner = shellRunner; this.configManager = configManager; this.scriptBuilder = scriptBuilder; @@ -57,22 +66,39 @@ public override async Task Execute(ToolProfile? profile, string? action) return; } - // Override default values of InstallOptions with values from config file - wizard.Options = await configManager.GetDefaultInstallOptions(); - var options = await wizard.Run(); - AnsiConsole.WriteLine(); + // Override default values of InstallDatabaseOptions and InstallProjectOptions with values from config file + dbWizard.Options = await configManager.GetDefaultInstallDatabaseOptions(); + projectWizard.Options = await configManager.GetDefaultInstallProjectOptions(); + + // Only install database if "db" argument is passed + InstallDatabaseOptions? dbOptions = null; + if (!string.IsNullOrEmpty(action) && action.Equals(DATABASE)) + { + dbOptions = await dbWizard.Run(InstallDatabaseWizard.SKIP_EXISTINGDB_STEP); + await InstallDatabaseTool(); + await CreateDatabase(dbOptions, true); + + return; + } + + var projectOptions = await projectWizard.Run(); + if (!IsAdminTemplate(projectOptions)) + { + dbOptions = await dbWizard.Run(); + } - newInstallationProfile.ProjectName = options.ProjectName; - newInstallationProfile.WorkingDirectory = $"{options.InstallRootPath}\\{options.ProjectName}"; + newInstallationProfile.ProjectName = projectOptions.ProjectName; + newInstallationProfile.WorkingDirectory = $"{projectOptions.InstallRootPath}\\{projectOptions.ProjectName}"; + AnsiConsole.WriteLine(); await CreateWorkingDirectory(); - await InstallTemplate(options); - await CreateProjectFiles(options); + await InstallTemplate(projectOptions); + await CreateProjectFiles(projectOptions); // Admin boilerplate project doesn't require database install or profile - if (!IsAdminTemplate(options)) + if (!IsAdminTemplate(projectOptions) && dbOptions is not null) { - await CreateDatabase(options); + await CreateDatabase(dbOptions, false); await configManager.AddProfile(newInstallationProfile); // Select new profile @@ -113,7 +139,7 @@ private async Task CreateWorkingDirectory() } - private async Task CreateDatabase(InstallOptions options) + private async Task CreateDatabase(InstallDatabaseOptions options, bool isDatabaseOnly) { if (StopProcessing) { @@ -125,6 +151,12 @@ private async Task CreateDatabase(InstallOptions options) string databaseScript = scriptBuilder.SetScript(ScriptType.DatabaseInstall) .WithPlaceholders(options) .Build(); + // Database-only install requires removal of "dotnet" from the script to run global tool + if (isDatabaseOnly) + { + databaseScript = databaseScript.Replace("dotnet", ""); + } + await shellRunner.Execute(new(databaseScript) { ErrorHandler = ErrorDataReceived, @@ -133,7 +165,7 @@ await shellRunner.Execute(new(databaseScript) } - private async Task CreateProjectFiles(InstallOptions options) + private async Task CreateProjectFiles(InstallProjectOptions options) { if (StopProcessing) { @@ -167,8 +199,43 @@ await shellRunner.Execute(new(installScript) }).WaitForExitAsync(); } + private async Task InstallDatabaseTool() + { + if (StopProcessing) + { + return; + } + + // Get desired database tool version + var versions = await NuGetVersionHelper.GetPackageVersions(Constants.DATABASE_TOOL); + var filtered = versions.Where(v => !v.IsPrerelease && !v.IsLegacyVersion && v.Major >= 25) + .Select(v => v.Version) + .OrderByDescending(v => v); + var toolVersion = AnsiConsole.Prompt(new SelectionPrompt() + .Title($"Which [{Constants.PROMPT_COLOR}]version[/]?") + .PageSize(10) + .UseConverter(v => $"{v.Major}.{v.Minor}.{v.Build}") + .MoreChoicesText("Scroll for more...") + .AddChoices(filtered)); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLineInterpolated($"[{Constants.EMPHASIS_COLOR}]Uninstalling previous database tool...[/]"); + + string uninstallScript = scriptBuilder.SetScript(ScriptType.UninstallDatabaseTool).Build(); + // Don't use base error handler for uninstall script as it throws when no tool is installed + // Just skip uninstall step in case of error and try to continue + await shellRunner.Execute(new(uninstallScript)).WaitForExitAsync(); + + AnsiConsole.MarkupLineInterpolated($"[{Constants.EMPHASIS_COLOR}]Installing database tool version {toolVersion}...[/]"); + + string installScript = scriptBuilder.SetScript(ScriptType.InstallDatabaseTool) + .AppendVersion(toolVersion) + .Build(); + await shellRunner.Execute(new(installScript) { ErrorHandler = ErrorDataReceived }).WaitForExitAsync(); + } + - private async Task InstallTemplate(InstallOptions options) + private async Task InstallTemplate(InstallProjectOptions options) { if (StopProcessing) { @@ -180,8 +247,7 @@ private async Task InstallTemplate(InstallOptions options) string uninstallScript = scriptBuilder.SetScript(ScriptType.TemplateUninstall).Build(); // Don't use base error handler for uninstall script as it throws when no templates are installed // Just skip uninstall step in case of error and try to continue - var uninstallCmd = shellRunner.Execute(new(uninstallScript)); - await uninstallCmd.WaitForExitAsync(); + await shellRunner.Execute(new(uninstallScript)).WaitForExitAsync(); AnsiConsole.MarkupLineInterpolated($"[{Constants.EMPHASIS_COLOR}]Installing template version {options.Version}...[/]"); @@ -189,11 +255,11 @@ private async Task InstallTemplate(InstallOptions options) .WithPlaceholders(options) .AppendVersion(options.Version) .Build(); - var installCmd = shellRunner.Execute(new(installScript) { ErrorHandler = ErrorDataReceived }); - await installCmd.WaitForExitAsync(); + await shellRunner.Execute(new(installScript) { ErrorHandler = ErrorDataReceived }).WaitForExitAsync(); } - private bool IsAdminTemplate(InstallOptions options) => options?.Template.Equals(Constants.TEMPLATE_ADMIN, StringComparison.OrdinalIgnoreCase) ?? false; + private static bool IsAdminTemplate(InstallProjectOptions options) => + options?.Template.Equals(Constants.TEMPLATE_ADMIN, StringComparison.OrdinalIgnoreCase) ?? false; } } diff --git a/src/Commands/MacroCommand.cs b/src/Commands/MacroCommand.cs index 07e6dfe..502fc89 100644 --- a/src/Commands/MacroCommand.cs +++ b/src/Commands/MacroCommand.cs @@ -20,7 +20,7 @@ public class MacroCommand : AbstractCommand public override IEnumerable Keywords => ["m", "macros"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => []; public override string Description => "Re-signs macro signatures"; @@ -92,10 +92,9 @@ private async Task ResignMacros(ProgressTask task, ToolProfile? profile, MacroOp } string originalDescription = task.Description; - string? salt = string.IsNullOrEmpty(options.OldSalt) ? options.NewSalt : options.OldSalt; string macroScript = scriptBuilder.SetScript(ScriptType.ResignMacros) .AppendSignAll(options.SignAll, options.UserName) - .AppendSalt(salt, !string.IsNullOrEmpty(options.OldSalt)) + .AppendSalts(options.OldSalt, options.NewSalt) .Build(); await shellRunner.Execute(new(macroScript) { diff --git a/src/Commands/SettingsCommand.cs b/src/Commands/SettingsCommand.cs index 0c4015d..2f8ae9c 100644 --- a/src/Commands/SettingsCommand.cs +++ b/src/Commands/SettingsCommand.cs @@ -23,7 +23,7 @@ public class SettingsCommand : AbstractCommand public override IEnumerable Keywords => ["s", "settings"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => []; public override string Description => "Configures the appsettings.json of a project"; @@ -57,12 +57,6 @@ public override async Task Execute(ToolProfile? profile, string? action) return; } - if (profile is null) - { - LogError("No active profile."); - return; - } - var options = await wizard.Run(); switch (options.SettingToChange) { @@ -90,7 +84,7 @@ public override async Task PostExecute(ToolProfile? profile, string? action) } - private async Task ConfigureHeadlessOptions(ToolProfile profile) + private async Task ConfigureHeadlessOptions(ToolProfile? profile) { if (StopProcessing) { @@ -136,7 +130,7 @@ private async Task ConfigureHeadlessOptions(ToolProfile profile) } - private async Task ConfigureConnectionString(ToolProfile profile) + private async Task ConfigureConnectionString(ToolProfile? profile) { if (StopProcessing) { @@ -160,7 +154,7 @@ private async Task ConfigureConnectionString(ToolProfile profile) } - private async Task ConfigureKeys(ToolProfile profile) + private async Task ConfigureKeys(ToolProfile? profile) { if (StopProcessing) { @@ -222,7 +216,7 @@ private async Task ConfigureKeys(ToolProfile profile) } - private async Task TryUpdateHeadlessOption(CmsHeadlessConfiguration headlessConfiguration, PropertyInfo propToUpdate, ToolProfile profile) + private async Task TryUpdateHeadlessOption(CmsHeadlessConfiguration headlessConfiguration, PropertyInfo propToUpdate, ToolProfile? profile) { bool isCachingKey = headlessConfiguration.Caching.GetType().GetProperties().Contains(propToUpdate); object? value = isCachingKey ? propToUpdate.GetValue(headlessConfiguration.Caching) : propToUpdate.GetValue(headlessConfiguration); @@ -264,7 +258,7 @@ private async Task TryUpdateHeadlessOption(CmsHeadlessConfiguration headle } - private string? Truncate(string? value, int maxLength, string truncationSuffix = "...") => value?.Length > maxLength + private static string? Truncate(string? value, int maxLength, string truncationSuffix = "...") => value?.Length > maxLength ? value[..maxLength] + truncationSuffix : value; } diff --git a/src/Commands/UpdateCommand.cs b/src/Commands/UpdateCommand.cs index 5dabd57..fb01f71 100644 --- a/src/Commands/UpdateCommand.cs +++ b/src/Commands/UpdateCommand.cs @@ -30,7 +30,7 @@ public class UpdateCommand : AbstractCommand public override IEnumerable Keywords => ["u", "update"]; - public override IEnumerable Parameters => Enumerable.Empty(); + public override IEnumerable Parameters => []; public override string Description => "Updates a project's NuGet packages and database version"; diff --git a/src/Configuration/ToolConfiguration.cs b/src/Configuration/ToolConfiguration.cs index 844102c..c00c8d5 100644 --- a/src/Configuration/ToolConfiguration.cs +++ b/src/Configuration/ToolConfiguration.cs @@ -26,9 +26,15 @@ public class ToolConfiguration /// - /// The stored in the configuration file. + /// The stored in the configuration file. /// - public InstallOptions? DefaultInstallOptions { get; set; } + public InstallProjectOptions? DefaultInstallProjectOptions { get; set; } + + + /// + /// The stored in the configuration file. + /// + public InstallDatabaseOptions? DefaultInstallDatabaseOptions { get; set; } /// diff --git a/src/Constants.cs b/src/Constants.cs index 55be231..8fb41e7 100644 --- a/src/Constants.cs +++ b/src/Constants.cs @@ -15,6 +15,7 @@ public static class Constants public const string EMPHASIS_COLOR = "deepskyblue3_1"; public const string PROMPT_COLOR = "lightgoldenrod2_2"; + public const string DATABASE_TOOL = "Kentico.Xperience.DbManager"; public const string TEMPLATES_PACKAGE = "kentico.xperience.templates"; public const string TEMPLATE_SAMPLE = "kentico-xperience-sample-mvc"; public const string TEMPLATE_BLANK = "kentico-xperience-mvc"; diff --git a/src/Kentico.Xperience.Manager.csproj b/src/Kentico.Xperience.Manager.csproj index f5cc8fc..6df0b9a 100644 --- a/src/Kentico.Xperience.Manager.csproj +++ b/src/Kentico.Xperience.Manager.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Options/InstallOptions.cs b/src/Options/InstallDatabaseOptions.cs similarity index 68% rename from src/Options/InstallOptions.cs rename to src/Options/InstallDatabaseOptions.cs index ab732e3..0103923 100644 --- a/src/Options/InstallOptions.cs +++ b/src/Options/InstallDatabaseOptions.cs @@ -5,45 +5,20 @@ namespace Xperience.Manager.Options { /// - /// The options used to install Xperience by Kentico project files and databases, used by . + /// The options used to install Xperience by Kentico databases, used by . /// - public class InstallOptions : IWizardOptions + public class InstallDatabaseOptions : IWizardOptions { /// - /// The version of the Xperience by Kentico templates and database to install. - /// - public Version? Version { get; set; } - - - /// - /// The name of the template to install. - /// - public string Template { get; set; } = "kentico-xperience-sample-mvc"; - - - /// - /// The name of the Xperience by Kentico project. - /// - public string ProjectName { get; set; } = "xbk"; - - - /// - /// The absolute path of the parent directory to install the project within. E.g. if set to - /// "C:\inetpub\wwwroot" a new installation will be created in "C:\inetpub\wwwroot\projectname." - /// - public string InstallRootPath { get; set; } = Environment.CurrentDirectory; - - - /// - /// If true, the "--cloud" parameter is used during installation. + /// The name of the new database. /// - public bool UseCloud { get; set; } = false; + public string DatabaseName { get; set; } = "xperience"; /// - /// The name of the new database. + /// If true, a new database will not be installed and the will be used. /// - public string DatabaseName { get; set; } = "xperience"; + public bool UseExistingDatabase { get; set; } = false; /// diff --git a/src/Options/InstallProjectOptions.cs b/src/Options/InstallProjectOptions.cs new file mode 100644 index 0000000..363df28 --- /dev/null +++ b/src/Options/InstallProjectOptions.cs @@ -0,0 +1,40 @@ +using Xperience.Manager.Commands; + +namespace Xperience.Manager.Options +{ + /// + /// The options used to install Xperience by Kentico project files, used by . + /// + public class InstallProjectOptions : IWizardOptions + { + /// + /// The version of the Xperience by Kentico templates and database to install. + /// + public Version? Version { get; set; } + + + /// + /// The name of the template to install. + /// + public string Template { get; set; } = "kentico-xperience-sample-mvc"; + + + /// + /// The name of the Xperience by Kentico project. + /// + public string ProjectName { get; set; } = "xbk"; + + + /// + /// The absolute path of the parent directory to install the project within. E.g. if set to + /// "C:\inetpub\wwwroot" a new installation will be created in "C:\inetpub\wwwroot\projectname." + /// + public string InstallRootPath { get; set; } = Environment.CurrentDirectory; + + + /// + /// If true, the "--cloud" parameter is used during installation. + /// + public bool UseCloud { get; set; } = false; + } +} diff --git a/src/Options/RunSqlOptions.cs b/src/Options/RunSqlOptions.cs new file mode 100644 index 0000000..b72d741 --- /dev/null +++ b/src/Options/RunSqlOptions.cs @@ -0,0 +1,21 @@ +using Xperience.Manager.Services; + +namespace Xperience.Manager.Options +{ + /// + /// The options used to run a SQL query via the script. + /// + public class RunSqlOptions : IWizardOptions + { + /// + /// The connection string which determines the database to connect to. + /// + public string? ConnString { get; set; } + + + /// + /// The query to execute. + /// + public string? SqlQuery { get; set; } + } +} diff --git a/src/Services/AppSettingsManager.cs b/src/Services/AppSettingsManager.cs index 51d7dc5..b6663e3 100644 --- a/src/Services/AppSettingsManager.cs +++ b/src/Services/AppSettingsManager.cs @@ -10,7 +10,7 @@ public class AppSettingsManager : IAppSettingsManager private readonly string cmsHeadlessSection = "CMSHeadless"; - public async Task GetConnectionString(ToolProfile profile, string name) + public async Task GetConnectionString(ToolProfile? profile, string name) { var appSettings = await LoadSettings(profile); var connectionStrings = appSettings["ConnectionStrings"]; @@ -23,7 +23,7 @@ public class AppSettingsManager : IAppSettingsManager } - public async Task GetCmsHeadlessConfiguration(ToolProfile profile) + public async Task GetCmsHeadlessConfiguration(ToolProfile? profile) { var appSettings = await LoadSettings(profile); var headlessConfig = appSettings.GetValue(cmsHeadlessSection)?.ToObject(); @@ -36,7 +36,7 @@ public async Task GetCmsHeadlessConfiguration(ToolProf } - public async Task> GetConfigurationKeys(ToolProfile profile) + public async Task> GetConfigurationKeys(ToolProfile? profile) { var appSettings = await LoadSettings(profile); var populatedKeys = Constants.ConfigurationKeys @@ -50,7 +50,7 @@ public async Task> GetConfigurationKeys(ToolProfil } - public async Task SetCmsHeadlessConfiguration(ToolProfile profile, CmsHeadlessConfiguration headlessConfiguration) + public async Task SetCmsHeadlessConfiguration(ToolProfile? profile, CmsHeadlessConfiguration headlessConfiguration) { var appSettings = await LoadSettings(profile); appSettings[cmsHeadlessSection] = JToken.FromObject(headlessConfiguration); @@ -59,7 +59,7 @@ public async Task SetCmsHeadlessConfiguration(ToolProfile profile, CmsHeadlessCo } - public async Task SetConnectionString(ToolProfile profile, string name, string connectionString) + public async Task SetConnectionString(ToolProfile? profile, string name, string connectionString) { var appSettings = await LoadSettings(profile); var connectionStrings = appSettings["ConnectionStrings"] ?? throw new InvalidOperationException("ConnectionStrings section not found."); @@ -69,7 +69,7 @@ public async Task SetConnectionString(ToolProfile profile, string name, string c } - public async Task SetKeyValue(ToolProfile profile, string keyName, object value) + public async Task SetKeyValue(ToolProfile? profile, string keyName, object value) { var appSettings = await LoadSettings(profile); appSettings[keyName] = JToken.FromObject(value); @@ -78,10 +78,21 @@ public async Task SetKeyValue(ToolProfile profile, string keyName, object value) } - private string GetAppSettingsPath(ToolProfile profile) => $"{profile.WorkingDirectory}/appsettings.json"; + private static string GetAppSettingsPath(ToolProfile profile) => $"{profile.WorkingDirectory}/appsettings.json"; - private async Task LoadSettings(ToolProfile profile) + private static Task LoadSettings(ToolProfile? profile) + { + if (profile is null) + { + throw new ArgumentNullException(nameof(profile)); + } + + return LoadSettingsInternal(profile); + } + + + private static async Task LoadSettingsInternal(ToolProfile profile) { string settingsPath = GetAppSettingsPath(profile); if (!File.Exists(settingsPath)) @@ -95,7 +106,18 @@ private async Task LoadSettings(ToolProfile profile) } - private async Task WriteAppSettings(ToolProfile profile, JObject appSettings) + private static Task WriteAppSettings(ToolProfile? profile, JObject appSettings) + { + if (profile is null) + { + throw new ArgumentNullException(nameof(profile)); + } + + return WriteAppSettingsInternal(profile, appSettings); + } + + + private static async Task WriteAppSettingsInternal(ToolProfile profile, JObject appSettings) { string settingsPath = GetAppSettingsPath(profile); diff --git a/src/Services/ConfigManager.cs b/src/Services/ConfigManager.cs index ae6ff9b..0ea26f7 100644 --- a/src/Services/ConfigManager.cs +++ b/src/Services/ConfigManager.cs @@ -1,6 +1,7 @@ using System.Reflection; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xperience.Manager.Configuration; using Xperience.Manager.Options; @@ -9,25 +10,25 @@ namespace Xperience.Manager.Services { public class ConfigManager : IConfigManager { - public async Task AddProfile(ToolProfile profile) + public Task AddProfile(ToolProfile? profile) { - var config = await GetConfig(); - if (config.Profiles.Any(p => p.ProjectName?.Equals(profile.ProjectName, StringComparison.OrdinalIgnoreCase) ?? false)) + if (profile is null) { - throw new InvalidOperationException($"There is already a profile named '{profile.ProjectName}.'"); + throw new ArgumentNullException(nameof(profile)); } - config.Profiles.Add(profile); - - await WriteConfig(config); + return AddProfileInternal(profile); } - public async Task SetCurrentProfile(ToolProfile profile) + public Task SetCurrentProfile(ToolProfile? profile) { - var config = await GetConfig(); - config.CurrentProfile = profile.ProjectName; - await WriteConfig(config); + if (profile is null) + { + throw new ArgumentNullException(nameof(profile)); + } + + return SetCurrentProfileInternal(profile); } @@ -69,7 +70,8 @@ public async Task GetConfig() public async Task EnsureConfigFile() { - var toolVersion = Assembly.GetExecutingAssembly().GetName().Version ?? throw new InvalidOperationException("The tool version couldn't be retrieved."); + var toolVersion = Assembly.GetExecutingAssembly().GetName().Version ?? + throw new InvalidOperationException("The tool version couldn't be retrieved."); if (File.Exists(Constants.CONFIG_FILENAME)) { await MigrateConfig(toolVersion); @@ -79,28 +81,48 @@ public async Task EnsureConfigFile() await WriteConfig(new ToolConfiguration { Version = toolVersion, - DefaultInstallOptions = new() + DefaultInstallProjectOptions = new(), + DefaultInstallDatabaseOptions = new() }); } - public async Task GetDefaultInstallOptions() + public async Task GetDefaultInstallProjectOptions() { var config = await GetConfig(); - return config.DefaultInstallOptions ?? new(); + return config.DefaultInstallProjectOptions ?? new(); } - public async Task RemoveProfile(ToolProfile profile) + public async Task GetDefaultInstallDatabaseOptions() { var config = await GetConfig(); - // For some reason Profiles.Remove() didn't work, make a new list - var newProfiles = new List(); - newProfiles.AddRange(config.Profiles.Where(p => !p.ProjectName?.Equals(profile.ProjectName, StringComparison.OrdinalIgnoreCase) ?? true)); + return config.DefaultInstallDatabaseOptions ?? new(); + } - config.Profiles = newProfiles; + + public Task RemoveProfile(ToolProfile? profile) + { + if (profile is null) + { + throw new ArgumentNullException(nameof(profile)); + } + + return RemoveProfileInternal(profile); + } + + + private async Task AddProfileInternal(ToolProfile profile) + { + var config = await GetConfig(); + if (config.Profiles.Any(p => p.ProjectName?.Equals(profile.ProjectName, StringComparison.OrdinalIgnoreCase) ?? false)) + { + throw new InvalidOperationException($"There is already a profile named '{profile.ProjectName}.'"); + } + + config.Profiles.Add(profile); await WriteConfig(config); } @@ -115,12 +137,67 @@ private async Task MigrateConfig(Version toolVersion) } // Perform any migrations from old config version to new version here + string text = await File.ReadAllTextAsync(Constants.CONFIG_FILENAME); + var json = JsonConvert.DeserializeObject(text) ?? + throw new InvalidOperationException("Unable to read configuration file for migration."); + if ((config.Version?.ToString().Equals("4.0.0.0") ?? false) && toolVersion.ToString().Equals("5.0.0.0")) + { + Migrate40To50(json, config); + } + config.Version = toolVersion; await WriteConfig(config); } - private Task WriteConfig(ToolConfiguration config) => File.WriteAllTextAsync(Constants.CONFIG_FILENAME, JsonConvert.SerializeObject(config, Formatting.Indented)); + private async Task RemoveProfileInternal(ToolProfile profile) + { + var config = await GetConfig(); + + // For some reason Profiles.Remove() didn't work, make a new list + var newProfiles = new List(); + newProfiles.AddRange(config.Profiles.Where(p => !p.ProjectName?.Equals(profile.ProjectName, StringComparison.OrdinalIgnoreCase) ?? true)); + + config.Profiles = newProfiles; + + await WriteConfig(config); + } + + + private async Task SetCurrentProfileInternal(ToolProfile profile) + { + var config = await GetConfig(); + config.CurrentProfile = profile.ProjectName; + await WriteConfig(config); + } + + + private static Task WriteConfig(ToolConfiguration config) => + File.WriteAllTextAsync(Constants.CONFIG_FILENAME, JsonConvert.SerializeObject(config, Formatting.Indented)); + + + private static void Migrate40To50(JObject oldConfig, ToolConfiguration newConfig) + { + var oldInstallOptions = oldConfig["DefaultInstallOptions"]; + + var dbOptions = new InstallDatabaseOptions(); + dbOptions.DatabaseName = oldInstallOptions?["DatabaseName"]?.ToString() ?? dbOptions.DatabaseName; + dbOptions.ServerName = oldInstallOptions?["ServerName"]?.ToString() ?? dbOptions.ServerName; + newConfig.DefaultInstallDatabaseOptions = dbOptions; + + var projectOptions = new InstallProjectOptions(); + projectOptions.Template = oldInstallOptions?["Template"]?.ToString() ?? projectOptions.Template; + projectOptions.ProjectName = oldInstallOptions?["ProjectName"]?.ToString() ?? projectOptions.ProjectName; + projectOptions.InstallRootPath = oldInstallOptions?["InstallRootPath"]?.ToString() ?? projectOptions.InstallRootPath; + projectOptions.UseCloud = bool.Parse(oldInstallOptions?["UseCloud"]?.ToString() ?? projectOptions.UseCloud.ToString()); + string? oldVersion = oldInstallOptions?["Version"]?.ToString(); + if (!string.IsNullOrEmpty(oldVersion)) + { + projectOptions.Version = Version.Parse(oldVersion); + } + + newConfig.DefaultInstallProjectOptions = projectOptions; + } } } diff --git a/src/Services/Interfaces/IAppSettingsManager.cs b/src/Services/Interfaces/IAppSettingsManager.cs index 62ab046..4302acf 100644 --- a/src/Services/Interfaces/IAppSettingsManager.cs +++ b/src/Services/Interfaces/IAppSettingsManager.cs @@ -10,38 +10,38 @@ public interface IAppSettingsManager : IService /// /// Gets the value of the connection string specified by the . /// - public Task GetConnectionString(ToolProfile profile, string name); + public Task GetConnectionString(ToolProfile? profile, string name); /// /// Gets the headless options. See /// . /// - public Task GetCmsHeadlessConfiguration(ToolProfile profile); + public Task GetCmsHeadlessConfiguration(ToolProfile? profile); /// /// Gets configurations keys. See /// . /// - public Task> GetConfigurationKeys(ToolProfile profile); + public Task> GetConfigurationKeys(ToolProfile? profile); /// /// Writes the headless configuration to the appsettings.json. /// - public Task SetCmsHeadlessConfiguration(ToolProfile profile, CmsHeadlessConfiguration headlessConfiguration); + public Task SetCmsHeadlessConfiguration(ToolProfile? profile, CmsHeadlessConfiguration headlessConfiguration); /// /// Writes the connection string to the appsettings.json. /// - public Task SetConnectionString(ToolProfile profile, string name, string connectionString); + public Task SetConnectionString(ToolProfile? profile, string name, string connectionString); /// /// Writes a configuration key value to the appsettings.json. /// - public Task SetKeyValue(ToolProfile profile, string keyName, object value); + public Task SetKeyValue(ToolProfile? profile, string keyName, object value); } } diff --git a/src/Services/Interfaces/IConfigManager.cs b/src/Services/Interfaces/IConfigManager.cs index 38cb9d1..b095e2f 100644 --- a/src/Services/Interfaces/IConfigManager.cs +++ b/src/Services/Interfaces/IConfigManager.cs @@ -17,7 +17,7 @@ public interface IConfigManager : IService /// /// Adds a profile to the . /// - public Task AddProfile(ToolProfile profile); + public Task AddProfile(ToolProfile? profile); /// @@ -34,21 +34,28 @@ public interface IConfigManager : IService /// - /// Gets the specified by the tool configuration file, or a new instance if + /// Gets the specified by the tool configuration file, or a new instance if /// the configuration can't be read. /// - public Task GetDefaultInstallOptions(); + public Task GetDefaultInstallProjectOptions(); + + + /// + /// Gets the specified by the tool configuration file, or a new instance if + /// the configuration can't be read. + /// + public Task GetDefaultInstallDatabaseOptions(); /// /// Removes a profile to the . /// - public Task RemoveProfile(ToolProfile profile); + public Task RemoveProfile(ToolProfile? profile); /// /// Sets the currently active profile. /// - public Task SetCurrentProfile(ToolProfile profile); + public Task SetCurrentProfile(ToolProfile? profile); } } diff --git a/src/Services/Interfaces/IScriptBuilder.cs b/src/Services/Interfaces/IScriptBuilder.cs index 95d61a2..382788f 100644 --- a/src/Services/Interfaces/IScriptBuilder.cs +++ b/src/Services/Interfaces/IScriptBuilder.cs @@ -26,11 +26,11 @@ public interface IScriptBuilder : IService /// - /// Appends "--old-salt" or "--new-salt" to the script if the script is . + /// Appends "--old-salt" and/or "--new-salt" to the script if the script is . /// - /// The salt value appended to the script. If not provided, the salt from appsettings is used. - /// If true, "--old-salt" is appended. - public IScriptBuilder AppendSalt(string? salt, bool isOld); + /// The old salt value appended to the script. + /// The new salt value appended to the script. If not provided, the salt from appsettings is used. + public IScriptBuilder AppendSalts(string? oldSalt, string? newSalt); /// @@ -64,6 +64,6 @@ public interface IScriptBuilder : IService /// Replaces script placeholders with the values of the object properties. If a property is null or emtpy, /// the placeholder remains in the script. /// - public IScriptBuilder WithPlaceholders(object dataObject); + public IScriptBuilder WithPlaceholders(object? dataObject); } } diff --git a/src/Services/ScriptBuilder.cs b/src/Services/ScriptBuilder.cs index 2a6fa01..42a2e59 100644 --- a/src/Services/ScriptBuilder.cs +++ b/src/Services/ScriptBuilder.cs @@ -10,12 +10,14 @@ public class ScriptBuilder : IScriptBuilder private const string BUILD_SCRIPT = "dotnet build"; private const string MKDIR_SCRIPT = $"mkdir"; - private const string INSTALL_PROJECT_SCRIPT = $"dotnet new {nameof(InstallOptions.Template)} -n {nameof(InstallOptions.ProjectName)}"; - private const string INSTALL_DATABASE_SCRIPT = $"dotnet kentico-xperience-dbmanager -- -s \"{nameof(InstallOptions.ServerName)}\" -d \"{nameof(InstallOptions.DatabaseName)}\" -a \"{nameof(InstallOptions.AdminPassword)}\""; - private const string UNINSTALL_TEMPLATE_SCRIPT = "dotnet new uninstall kentico.xperience.templates"; - private const string INSTALL_TEMPLATE_SCRIPT = "dotnet new install kentico.xperience.templates"; + private const string INSTALL_PROJECT_SCRIPT = $"dotnet new {nameof(InstallProjectOptions.Template)} -n {nameof(InstallProjectOptions.ProjectName)}"; + private const string INSTALL_DATABASE_SCRIPT = $"dotnet kentico-xperience-dbmanager -- -s \"{nameof(InstallDatabaseOptions.ServerName)}\" -d \"{nameof(InstallDatabaseOptions.DatabaseName)}\" -a \"{nameof(InstallDatabaseOptions.AdminPassword)}\" --use-existing-database {nameof(InstallDatabaseOptions.UseExistingDatabase)}"; + private const string UNINSTALL_TEMPLATE_SCRIPT = $"dotnet new uninstall {Constants.TEMPLATES_PACKAGE}"; + private const string INSTALL_TEMPLATE_SCRIPT = $"dotnet new install {Constants.TEMPLATES_PACKAGE}"; private const string UPDATE_PACKAGE_SCRIPT = $"dotnet add package {nameof(UpdateOptions.PackageName)}"; private const string UPDATE_DATABASE_SCRIPT = "dotnet run --no-build --kxp-update -- --skip-confirmation"; + private const string INSTALL_DBTOOL_SCRIPT = $"dotnet tool install {Constants.DATABASE_TOOL} -g"; + private const string UNINSTALL_DBTOOL_SCRIPT = $"dotnet tool uninstall {Constants.DATABASE_TOOL} -g"; private const string CI_STORE_SCRIPT = "dotnet run --no-build --kxp-ci-store"; private const string CI_RESTORE_SCRIPT = "dotnet run --no-build --kxp-ci-restore"; private const string CD_NEW_CONFIG_SCRIPT = $"dotnet run --no-build -- --kxp-cd-config --path \"{nameof(ContinuousDeploymentConfig.ConfigPath)}\""; @@ -23,6 +25,8 @@ public class ScriptBuilder : IScriptBuilder private const string CD_RESTORE_SCRIPT = $"dotnet run -- --kxp-cd-restore --repository-path \"{nameof(ContinuousDeploymentConfig.RepositoryPath)}\""; private const string MACRO_SCRIPT = "dotnet run --no-build -- --kxp-resign-macros"; private const string CODEGEN_SCRIPT = $"dotnet run -- --kxp-codegen --skip-confirmation --type \"{nameof(CodeGenerateOptions.Type)}\" --location \"{nameof(CodeGenerateOptions.Location)}\" --include \"{nameof(CodeGenerateOptions.Include)}\" --exclude \"{nameof(CodeGenerateOptions.Exclude)}\" --with-provider-class {nameof(CodeGenerateOptions.WithProviderClass)}"; + private const string DELETE_FOLDER_SCRIPT = $"rm \"{nameof(ToolProfile.WorkingDirectory)}\" -r -Force"; + private const string RUN_SQL_QUERY = $"Invoke-Sqlcmd -ConnectionString \"{nameof(RunSqlOptions.ConnString)}\" -Query \"{nameof(RunSqlOptions.SqlQuery)}\""; public IScriptBuilder AppendCloud(bool useCloud) @@ -58,14 +62,22 @@ public IScriptBuilder AppendNamespace(string? nameSpace) } - public IScriptBuilder AppendSalt(string? salt, bool isOld) + public IScriptBuilder AppendSalts(string? oldSalt, string? newSalt) { - if (string.IsNullOrEmpty(salt) || !currentScriptType.Equals(ScriptType.ResignMacros)) + if (!currentScriptType.Equals(ScriptType.ResignMacros)) { return this; } - currentScript += $" {(isOld ? "--old-salt" : "--new-salt")} \"{salt}\""; + if (!string.IsNullOrEmpty(oldSalt)) + { + currentScript += $" --old-salt \"{oldSalt}\""; + } + + if (!string.IsNullOrEmpty(newSalt)) + { + currentScript += $" --new-salt \"{newSalt}\""; + } return this; } @@ -95,7 +107,7 @@ public IScriptBuilder AppendVersion(Version? version) { currentScript += $"::{version}"; } - else if (currentScriptType.Equals(ScriptType.PackageUpdate)) + else if (currentScriptType.Equals(ScriptType.PackageUpdate) || currentScriptType.Equals(ScriptType.InstallDatabaseTool)) { currentScript += $" --version {version}"; } @@ -106,17 +118,22 @@ public IScriptBuilder AppendVersion(Version? version) public string Build() { - if (!ValidateScript()) + if (string.IsNullOrEmpty(currentScript)) { - throw new InvalidOperationException("The script is empty or contains placeholder values."); + throw new InvalidOperationException("The script is empty."); } return currentScript; } - public IScriptBuilder WithPlaceholders(object dataObject) + public IScriptBuilder WithPlaceholders(object? dataObject) { + if (dataObject is null) + { + return this; + } + // Replace all placeholders in script with object values if non-null or empty foreach (var prop in dataObject.GetType().GetProperties()) { @@ -156,20 +173,16 @@ public IScriptBuilder SetScript(ScriptType type) ScriptType.ContinuousDeploymentRestore => CD_RESTORE_SCRIPT, ScriptType.ResignMacros => MACRO_SCRIPT, ScriptType.GenerateCode => CODEGEN_SCRIPT, + ScriptType.DeleteDirectory => DELETE_FOLDER_SCRIPT, + ScriptType.ExecuteSql => RUN_SQL_QUERY, + ScriptType.UninstallDatabaseTool => UNINSTALL_DBTOOL_SCRIPT, + ScriptType.InstallDatabaseTool => INSTALL_DBTOOL_SCRIPT, ScriptType.None => string.Empty, _ => string.Empty, }; return this; } - - - private bool ValidateScript() - { - var propertyNames = typeof(InstallOptions).GetProperties().Select(p => p.Name); - - return !string.IsNullOrEmpty(currentScript) && !propertyNames.Any(currentScript.Contains); - } } @@ -264,9 +277,34 @@ public enum ScriptType /// ResignMacros, + /// /// The script which generates code files for Xperience objects. /// GenerateCode, + + + /// + /// The script which deletes a local folder and its contents. + /// + DeleteDirectory, + + + /// + /// The script which executes a SQL query against a database. + /// + ExecuteSql, + + + /// + /// The script which uninstalls the Kentico.Xperience.DbManager global tool. + /// + UninstallDatabaseTool, + + + /// + /// The script which installs the Kentico.Xperience.DbManager global tool. + /// + InstallDatabaseTool, } } diff --git a/src/Wizards/Base/AbstractWizard.cs b/src/Wizards/Base/AbstractWizard.cs index 6911791..06cab1f 100644 --- a/src/Wizards/Base/AbstractWizard.cs +++ b/src/Wizards/Base/AbstractWizard.cs @@ -15,12 +15,12 @@ namespace Xperience.Manager.Wizards public TOptions Options { get; set; } = new(); - public abstract Task InitSteps(); + public abstract Task InitSteps(params string[] args); - public async Task Run() + public async Task Run(params string[] args) { - await InitSteps(); + await InitSteps(args); do { await Steps.Current.Execute(); diff --git a/src/Wizards/Base/IWizard.cs b/src/Wizards/Base/IWizard.cs index 7718e7a..6e79918 100644 --- a/src/Wizards/Base/IWizard.cs +++ b/src/Wizards/Base/IWizard.cs @@ -17,17 +17,18 @@ public interface IWizard where TOptions : IWizardOptions public TOptions Options { get; set; } - /// /// Initializes the with the s required to /// populate the . /// - public Task InitSteps(); + /// Optional arguments to pass to the step initialization. + public Task InitSteps(params string[] args); /// /// Requests user input to generate the . /// - public Task Run(); + /// Optional arguments to pass to the wizard. + public Task Run(params string[] args); } } diff --git a/src/Services/CodeGenerateWizard.cs b/src/Wizards/CodeGenerateWizard.cs similarity index 97% rename from src/Services/CodeGenerateWizard.cs rename to src/Wizards/CodeGenerateWizard.cs index 1389913..89a6f6c 100644 --- a/src/Services/CodeGenerateWizard.cs +++ b/src/Wizards/CodeGenerateWizard.cs @@ -19,7 +19,7 @@ public class CodeGenerateWizard : AbstractWizard ]; - public override Task InitSteps() + public override Task InitSteps(params string[] args) { Steps.Add(new Step(new() { diff --git a/src/Wizards/InstallDatabaseWizard.cs b/src/Wizards/InstallDatabaseWizard.cs new file mode 100644 index 0000000..7c7cd37 --- /dev/null +++ b/src/Wizards/InstallDatabaseWizard.cs @@ -0,0 +1,59 @@ +using Spectre.Console; + +using Xperience.Manager.Options; +using Xperience.Manager.Steps; + +namespace Xperience.Manager.Wizards +{ + /// + /// A wizard which generates an for installing Xperience by Kentico databases. + /// + public class InstallDatabaseWizard : AbstractWizard + { + public const string SKIP_EXISTINGDB_STEP = "skipexistingdbstep"; + + + public override Task InitSteps(params string[] args) + { + var serverPrompt = new TextPrompt($"Enter the [{Constants.PROMPT_COLOR}]SQL server[/] name:"); + if (!string.IsNullOrEmpty(Options.ServerName)) + { + serverPrompt.DefaultValue(Options.ServerName); + } + Steps.Add(new Step(new() + { + Prompt = serverPrompt, + ValueReceiver = (v) => Options.ServerName = v + })); + + Steps.Add(new Step(new() + { + Prompt = new TextPrompt($"Enter the [{Constants.PROMPT_COLOR}]database[/] name:") + .AllowEmpty() + .DefaultValue(Options.DatabaseName), + ValueReceiver = (v) => Options.DatabaseName = v + })); + + var useExistingPrompt = new ConfirmationPrompt($"Use [{Constants.PROMPT_COLOR}]existing[/] database?") + { + DefaultValue = Options.UseExistingDatabase + }; + Steps.Add(new Step(new() + { + Prompt = useExistingPrompt, + ValueReceiver = (v) => Options.UseExistingDatabase = v, + SkipChecker = () => args.Contains(SKIP_EXISTINGDB_STEP) + })); + + Steps.Add(new Step(new() + { + Prompt = new TextPrompt($"Enter the admin [{Constants.PROMPT_COLOR}]password[/]:") + .AllowEmpty() + .DefaultValue(Options.AdminPassword), + ValueReceiver = (v) => Options.AdminPassword = v + })); + + return Task.CompletedTask; + } + } +} diff --git a/src/Wizards/InstallWizard.cs b/src/Wizards/InstallProjectWizard.cs similarity index 64% rename from src/Wizards/InstallWizard.cs rename to src/Wizards/InstallProjectWizard.cs index 61c73d7..eebcb8c 100644 --- a/src/Wizards/InstallWizard.cs +++ b/src/Wizards/InstallProjectWizard.cs @@ -7,9 +7,9 @@ namespace Xperience.Manager.Wizards { /// - /// A wizard which generates an for installing Xperience by Kentico. + /// A wizard which generates an for installing Xperience by Kentico project files. /// - public class InstallWizard : AbstractWizard + public class InstallProjectWizard : AbstractWizard { private readonly IEnumerable templates = [ Constants.TEMPLATE_SAMPLE, @@ -18,7 +18,7 @@ public class InstallWizard : AbstractWizard ]; - public override async Task InitSteps() + public override async Task InitSteps(params string[] args) { var versions = await NuGetVersionHelper.GetPackageVersions(Constants.TEMPLATES_PACKAGE); var filtered = versions.Where(v => !v.IsPrerelease && !v.IsLegacyVersion && v.Major >= 25) @@ -68,37 +68,6 @@ public override async Task InitSteps() ValueReceiver = (v) => Options.UseCloud = v, SkipChecker = IsAdminTemplate })); - - - var serverPrompt = new TextPrompt($"Enter the [{Constants.PROMPT_COLOR}]SQL server[/] name:"); - if (!string.IsNullOrEmpty(Options.ServerName)) - { - serverPrompt.DefaultValue(Options.ServerName); - } - Steps.Add(new Step(new() - { - Prompt = serverPrompt, - ValueReceiver = (v) => Options.ServerName = v, - SkipChecker = IsAdminTemplate - })); - - Steps.Add(new Step(new() - { - Prompt = new TextPrompt($"Enter the [{Constants.PROMPT_COLOR}]database[/] name:") - .AllowEmpty() - .DefaultValue(Options.DatabaseName), - ValueReceiver = (v) => Options.DatabaseName = v, - SkipChecker = IsAdminTemplate - })); - - Steps.Add(new Step(new() - { - Prompt = new TextPrompt($"Enter the admin [{Constants.PROMPT_COLOR}]password[/]:") - .AllowEmpty() - .DefaultValue(Options.AdminPassword), - ValueReceiver = (v) => Options.AdminPassword = v, - SkipChecker = IsAdminTemplate - })); } diff --git a/src/Wizards/MacroWizard.cs b/src/Wizards/MacroWizard.cs index 1b8a57f..2b0644c 100644 --- a/src/Wizards/MacroWizard.cs +++ b/src/Wizards/MacroWizard.cs @@ -10,7 +10,7 @@ namespace Xperience.Manager.Wizards /// public class MacroWizard : AbstractWizard { - public override Task InitSteps() + public override Task InitSteps(params string[] args) { Steps.Add(new Step(new() { diff --git a/src/Wizards/NewProfileWizard.cs b/src/Wizards/NewProfileWizard.cs index b72f518..7fbd1fe 100644 --- a/src/Wizards/NewProfileWizard.cs +++ b/src/Wizards/NewProfileWizard.cs @@ -10,7 +10,7 @@ namespace Xperience.Manager.Wizards /// public class NewProfileWizard : AbstractWizard { - public override Task InitSteps() + public override Task InitSteps(params string[] args) { Steps.Add(new Step(new() { diff --git a/src/Wizards/RepositoryConfigurationWizard.cs b/src/Wizards/RepositoryConfigurationWizard.cs index 6a269c6..a297eda 100644 --- a/src/Wizards/RepositoryConfigurationWizard.cs +++ b/src/Wizards/RepositoryConfigurationWizard.cs @@ -20,7 +20,7 @@ public class RepositoryConfigurationWizard : AbstractWizard(new() { diff --git a/src/Wizards/SettingsWizard.cs b/src/Wizards/SettingsWizard.cs index 1b18088..99c9257 100644 --- a/src/Wizards/SettingsWizard.cs +++ b/src/Wizards/SettingsWizard.cs @@ -10,7 +10,7 @@ namespace Xperience.Manager.Wizards /// public class SettingsWizard : AbstractWizard { - public override Task InitSteps() + public override Task InitSteps(params string[] args) { Steps.Add(new Step(new() { diff --git a/src/Wizards/UpdateWizard.cs b/src/Wizards/UpdateWizard.cs index 1ac4bc3..efbbd3a 100644 --- a/src/Wizards/UpdateWizard.cs +++ b/src/Wizards/UpdateWizard.cs @@ -11,7 +11,7 @@ namespace Xperience.Manager.Wizards /// public class UpdateWizard : AbstractWizard { - public override async Task InitSteps() + public override async Task InitSteps(params string[] args) { var versions = await NuGetVersionHelper.GetPackageVersions(Constants.TEMPLATES_PACKAGE); var filtered = versions.Where(v => !v.IsPrerelease && !v.IsLegacyVersion && v.Major >= 25) diff --git a/test/Data/config_with_installoptions.json b/test/Data/config_with_installoptions.json index 2a47ecd..ee60aab 100644 --- a/test/Data/config_with_installoptions.json +++ b/test/Data/config_with_installoptions.json @@ -1,14 +1,17 @@ { - "Version": "3.4.1.0", + "Version": "4.1.0.0", "CurrentProfile": null, - "DefaultInstallOptions": { - "DatabaseName": "mydb", + "DefaultInstallProjectOptions": { "ProjectName": "myproject", - "ServerName": "myserver", "Template": "kentico-xperience-sample-mvc", "UseCloud": true, "Version": null }, + "DefaultInstallDatabaseOptions": { + "DatabaseName": "mydb", + "UseExistingDatabase": false, + "ServerName": "myserver" + }, "Profiles": [], "CDRootPath": "c:\\ContinuousDeployment" } \ No newline at end of file diff --git a/test/Data/config_with_multiple_profiles.json b/test/Data/config_with_multiple_profiles.json index c0749e8..ebc75cd 100644 --- a/test/Data/config_with_multiple_profiles.json +++ b/test/Data/config_with_multiple_profiles.json @@ -1,14 +1,8 @@ { - "Version": "3.4.1.0", + "Version": "4.1.0.0", "CurrentProfile": null, - "DefaultInstallOptions": { - "DatabaseName": null, - "ProjectName": null, - "ServerName": null, - "Template": null, - "UseCloud": false, - "Version": null - }, + "DefaultInstallProjectOptions": null, + "DefaultInstallDatabaseOptions": null, "Profiles": [ { "ProjectName": "1", diff --git a/test/Data/config_with_one_profile.json b/test/Data/config_with_one_profile.json index 1d858d6..03aad3b 100644 --- a/test/Data/config_with_one_profile.json +++ b/test/Data/config_with_one_profile.json @@ -1,14 +1,8 @@ { - "Version": "3.4.1.0", + "Version": "4.1.0.0", "CurrentProfile": null, - "DefaultInstallOptions": { - "DatabaseName": null, - "ProjectName": null, - "ServerName": null, - "Template": null, - "UseCloud": false, - "Version": null - }, + "DefaultInstallProjectOptions": null, + "DefaultInstallDatabaseOptions": null, "Profiles": [ { "ProjectName": "1", diff --git a/test/Kentico.Xperience.Manager.Tests.csproj b/test/Kentico.Xperience.Manager.Tests.csproj index 73f8124..a6a3012 100644 --- a/test/Kentico.Xperience.Manager.Tests.csproj +++ b/test/Kentico.Xperience.Manager.Tests.csproj @@ -1,4 +1,4 @@ - + net7.0;net8.0 @@ -9,10 +9,10 @@ - + - + diff --git a/test/Tests/Commands/BuildCommandTests.cs b/test/Tests/Commands/BuildCommandTests.cs new file mode 100644 index 0000000..14f87ea --- /dev/null +++ b/test/Tests/Commands/BuildCommandTests.cs @@ -0,0 +1,32 @@ +using NSubstitute; + +using NUnit.Framework; + +using Xperience.Manager.Commands; +using Xperience.Manager.Services; + +namespace Xperience.Manager.Tests.Commands +{ + /// + /// Tests for . + /// + public class BuildCommandTests : TestBase + { + private readonly IShellRunner shellRunner = Substitute.For(); + + + [SetUp] + public void BuildCommandTestsSetUp() => shellRunner.Execute(Arg.Any()).Returns((x) => GetDummyProcess()); + + + [Test] + public async Task Execute_CallsBuildScript() + { + var command = new BuildCommand(shellRunner, new ScriptBuilder()); + await command.PreExecute(new(), string.Empty); + await command.Execute(new(), string.Empty); + + shellRunner.Received().Execute(Arg.Is(x => x.Script.Equals($"dotnet build"))); + } + } +} diff --git a/test/Tests/Commands/CodeGenerateCommandTests.cs b/test/Tests/Commands/CodeGenerateCommandTests.cs new file mode 100644 index 0000000..fdb9c3f --- /dev/null +++ b/test/Tests/Commands/CodeGenerateCommandTests.cs @@ -0,0 +1,56 @@ +using NSubstitute; + +using NUnit.Framework; + +using Xperience.Manager.Commands; +using Xperience.Manager.Options; +using Xperience.Manager.Services; +using Xperience.Manager.Wizards; + +namespace Xperience.Manager.Tests.Commands +{ + /// + /// Tests for . + /// + public class CodeGenerateCommandTests : TestBase + { + private const string EXCLUDE = "ex"; + private const string INCLUDE = "*"; + private const string LOCATION = "/dir"; + private const string NAMESPACE = "ns"; + private const string TYPE = CodeGenerateOptions.TYPE_REUSABLE_CONTENT_TYPES; + private const bool WITH_PROVIDER = false; + private readonly IShellRunner shellRunner = Substitute.For(); + private readonly IWizard generateWizard = Substitute.For>(); + + + [SetUp] + public void CodeGenerateCommandTestsSetUp() + { + generateWizard.Run().Returns(new CodeGenerateOptions + { + Exclude = EXCLUDE, + Include = INCLUDE, + Location = LOCATION, + Namespace = NAMESPACE, + Type = TYPE, + WithProviderClass = WITH_PROVIDER + }); + + shellRunner.Execute(Arg.Any()).Returns((x) => GetDummyProcess()); + } + + + [Test] + public async Task Execute_CallsCodeGenScript() + { + var command = new CodeGenerateCommand(shellRunner, new ScriptBuilder(), generateWizard); + await command.PreExecute(new(), string.Empty); + await command.Execute(new(), string.Empty); + + string expectedCodeGenScript = $"dotnet run -- --kxp-codegen --skip-confirmation --type \"{TYPE}\" --location \"{LOCATION}\" --include \"{INCLUDE}\" --exclude \"{EXCLUDE}\" --with-provider-class {WITH_PROVIDER} --namespace \"{NAMESPACE}\""; + + shellRunner.Received().Execute(Arg.Is(x => x.Script.Equals(expectedCodeGenScript))); + } + } +} diff --git a/test/Tests/Commands/InstallCommandTests.cs b/test/Tests/Commands/InstallCommandTests.cs index 641eca7..3608bff 100644 --- a/test/Tests/Commands/InstallCommandTests.cs +++ b/test/Tests/Commands/InstallCommandTests.cs @@ -19,22 +19,28 @@ public class InstallCommandTests : TestBase private const string SERVER_NAME = "TESTSERVER"; private const string TEMPLATE = "TEMPLATE"; private const string PROJECT_NAME = "PROJECT"; + private const bool USE_EXISTING = false; private readonly Version version = new(1, 0, 0); private readonly IShellRunner shellRunner = Substitute.For(); - private readonly IWizard installWizard = Substitute.For>(); + private readonly IWizard projectWizard = Substitute.For>(); + private readonly IWizard dbWizard = Substitute.For>(); [SetUp] public void InstallCommandTestsSetUp() { - installWizard.Run().Returns(new InstallOptions + projectWizard.Run().Returns(new InstallProjectOptions { ProjectName = PROJECT_NAME, + Version = version, + Template = TEMPLATE + }); + dbWizard.Run().Returns(new InstallDatabaseOptions + { AdminPassword = PASSWORD, DatabaseName = DB_NAME, ServerName = SERVER_NAME, - Version = version, - Template = TEMPLATE + UseExistingDatabase = USE_EXISTING }); shellRunner.Execute(Arg.Any()).Returns((x) => GetDummyProcess()); @@ -44,14 +50,14 @@ public void InstallCommandTestsSetUp() [Test] public async Task Execute_CallsInstallationScripts() { - var command = new InstallCommand(shellRunner, new ScriptBuilder(), installWizard, Substitute.For()); + var command = new InstallCommand(shellRunner, new ScriptBuilder(), projectWizard, dbWizard, Substitute.For()); await command.PreExecute(new(), string.Empty); await command.Execute(new(), string.Empty); string expectedProjectFileScript = $"dotnet new {TEMPLATE} -n {PROJECT_NAME}"; string expectedUninstallScript = "dotnet new uninstall kentico.xperience.templates"; string expectedTemplateScript = $"dotnet new install kentico.xperience.templates::{version}"; - string expectedDatabaseScript = $"dotnet kentico-xperience-dbmanager -- -s \"{SERVER_NAME}\" -d \"{DB_NAME}\" -a \"{PASSWORD}\""; + string expectedDatabaseScript = $"dotnet kentico-xperience-dbmanager -- -s \"{SERVER_NAME}\" -d \"{DB_NAME}\" -a \"{PASSWORD}\" --use-existing-database {USE_EXISTING}"; Assert.Multiple(() => { diff --git a/test/Tests/Commands/MacroCommandTests.cs b/test/Tests/Commands/MacroCommandTests.cs new file mode 100644 index 0000000..6af1811 --- /dev/null +++ b/test/Tests/Commands/MacroCommandTests.cs @@ -0,0 +1,64 @@ +using NSubstitute; + +using NUnit.Framework; + +using Xperience.Manager.Commands; +using Xperience.Manager.Options; +using Xperience.Manager.Services; +using Xperience.Manager.Wizards; + +namespace Xperience.Manager.Tests.Commands +{ + /// + /// Tests for . + /// + public class MacroCommandTests : TestBase + { + private const string USER = "admin"; + private const string OLD_SALT = "old"; + private const string NEW_SALT = "new"; + private readonly IShellRunner shellRunner = Substitute.For(); + private readonly IWizard macroWizard = Substitute.For>(); + + + [SetUp] + public void MacroCommandTestsSetUp() => shellRunner.Execute(Arg.Any()).Returns((x) => GetDummyProcess()); + + + [Test] + public async Task Execute_SignAll_CallsSignAllScript() + { + macroWizard.Run().Returns(new MacroOptions + { + SignAll = true, + UserName = USER, + NewSalt = NEW_SALT + }); + var command = new MacroCommand(macroWizard, shellRunner, new ScriptBuilder()); + await command.PreExecute(new(), string.Empty); + await command.Execute(new(), string.Empty); + + string expectedMacroScript = $"dotnet run --no-build -- --kxp-resign-macros --sign-all --username \"{USER}\" --new-salt \"{NEW_SALT}\""; + + shellRunner.Received().Execute(Arg.Is(x => x.Script.Equals(expectedMacroScript))); + } + + + [Test] + public async Task Execute_OldAndNewSalt_ScriptContainsSalts() + { + macroWizard.Run().Returns(new MacroOptions + { + OldSalt = OLD_SALT, + NewSalt = NEW_SALT + }); + var command = new MacroCommand(macroWizard, shellRunner, new ScriptBuilder()); + await command.PreExecute(new(), string.Empty); + await command.Execute(new(), string.Empty); + + string expectedMacroScript = $"dotnet run --no-build -- --kxp-resign-macros --old-salt \"{OLD_SALT}\" --new-salt \"{NEW_SALT}\""; + + shellRunner.Received().Execute(Arg.Is(x => x.Script.Equals(expectedMacroScript))); + } + } +} diff --git a/test/Tests/Services/IConfigManagerTests.cs b/test/Tests/Services/IConfigManagerTests.cs index 7bfab20..35dc429 100644 --- a/test/Tests/Services/IConfigManagerTests.cs +++ b/test/Tests/Services/IConfigManagerTests.cs @@ -97,15 +97,16 @@ public async Task GetDefaultInstallOptions_GetsCustomOptions() { File.Copy("Data/config_with_installoptions.json", Constants.CONFIG_FILENAME); - var options = await configManager.GetDefaultInstallOptions(); + var dbOptions = await configManager.GetDefaultInstallDatabaseOptions(); + var projectOptions = await configManager.GetDefaultInstallProjectOptions(); Assert.Multiple(() => { - Assert.That(options.UseCloud, Is.True); - Assert.That(options.DatabaseName, Is.EqualTo("mydb")); - Assert.That(options.ServerName, Is.EqualTo("myserver")); - Assert.That(options.ProjectName, Is.EqualTo("myproject")); - Assert.That(options.Template, Is.EqualTo("kentico-xperience-sample-mvc")); + Assert.That(dbOptions.DatabaseName, Is.EqualTo("mydb")); + Assert.That(dbOptions.ServerName, Is.EqualTo("myserver")); + Assert.That(projectOptions.UseCloud, Is.True); + Assert.That(projectOptions.ProjectName, Is.EqualTo("myproject")); + Assert.That(projectOptions.Template, Is.EqualTo("kentico-xperience-sample-mvc")); }); } } diff --git a/test/Tests/Services/IScriptBuilderTests.cs b/test/Tests/Services/IScriptBuilderTests.cs index 00dc0dc..d07fd24 100644 --- a/test/Tests/Services/IScriptBuilderTests.cs +++ b/test/Tests/Services/IScriptBuilderTests.cs @@ -11,13 +11,16 @@ namespace Xperience.Manager.Tests.Services public class IScriptBuilderTests { private readonly IScriptBuilder scriptBuilder = new ScriptBuilder(); - private readonly InstallOptions validInstallOptions = new() + private readonly InstallProjectOptions validProjectOptions = new() { ProjectName = "TEST", + Template = "kentico-xperience-sample-mvc" + }; + private readonly InstallDatabaseOptions validDatabaseOptions = new() + { AdminPassword = "PW", DatabaseName = "DB", - ServerName = "TESTSERVER", - Template = "kentico-xperience-sample-mvc" + ServerName = "SERVER" }; private readonly UpdateOptions validUpdateOptions = new() { PackageName = "kentico.xperience.webapp" }; @@ -25,29 +28,19 @@ public class IScriptBuilderTests [Test] public void ProjectInstallScript_WithValidOptions_ReturnsValidScript() { - string script = scriptBuilder.SetScript(ScriptType.ProjectInstall).WithPlaceholders(validInstallOptions).Build(); - string expected = $"dotnet new {validInstallOptions.Template} -n {validInstallOptions.ProjectName}"; + string script = scriptBuilder.SetScript(ScriptType.ProjectInstall).WithPlaceholders(validProjectOptions).Build(); + string expected = $"dotnet new {validProjectOptions.Template} -n {validProjectOptions.ProjectName}"; Assert.That(script, Is.EqualTo(expected)); } - [Test] - public void ProjectInstallScript_WithInvalidOptions_ThrowsException() - { - var options = new InstallOptions { Template = string.Empty }; - var builder = scriptBuilder.SetScript(ScriptType.ProjectInstall).WithPlaceholders(options); - - Assert.That(() => builder.Build(), Throws.InvalidOperationException); - } - - [Test] public void TemplateInstall_AppendVersion_AddsParameter() { var version = new Version(1, 0, 0); string script = scriptBuilder.SetScript(ScriptType.TemplateInstall) - .WithPlaceholders(validInstallOptions) + .WithPlaceholders(validProjectOptions) .AppendVersion(version) .Build(); string expected = $"dotnet new install kentico.xperience.templates::{version}"; @@ -73,20 +66,10 @@ public void PackageUpdate_AppendVersion_AddsParameter() [Test] public void DatabaseInstallScript_WithValidOptions_ReturnsValidScript() { - string script = scriptBuilder.SetScript(ScriptType.DatabaseInstall).WithPlaceholders(validInstallOptions).Build(); - string expected = $"dotnet kentico-xperience-dbmanager -- -s \"{validInstallOptions.ServerName}\" -d \"{validInstallOptions.DatabaseName}\" -a \"{validInstallOptions.AdminPassword}\""; + string script = scriptBuilder.SetScript(ScriptType.DatabaseInstall).WithPlaceholders(validDatabaseOptions).Build(); + string expected = $"dotnet kentico-xperience-dbmanager -- -s \"{validDatabaseOptions.ServerName}\" -d \"{validDatabaseOptions.DatabaseName}\" -a \"{validDatabaseOptions.AdminPassword}\" --use-existing-database {validDatabaseOptions.UseExistingDatabase}"; Assert.That(script, Is.EqualTo(expected)); } - - - [Test] - public void DatabaseInstallScript_WithInvalidOptions_ThrowsException() - { - var options = new InstallOptions(); - var builder = scriptBuilder.SetScript(ScriptType.DatabaseInstall).WithPlaceholders(options); - - Assert.That(() => builder.Build(), Throws.InvalidOperationException); - } } }