diff --git a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/BackupCommands.cs b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/BackupCommands.cs new file mode 100644 index 0000000..28d35f0 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/BackupCommands.cs @@ -0,0 +1,347 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Commands; + +/// +/// Backup management command implementations. +/// +public static class BackupCommands +{ + /// + /// Creates the backup create command. + /// + public static Command CreateCreateCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("create", "Create backup of appsettings.json"); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet) => + { + var exitCode = await CreateBackupAsync(serviceProvider, configPath, verbose, quiet); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption); + + return command; + } + + /// + /// Creates the backup list command. + /// + public static Command CreateListCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("list", "List available backups"); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet) => + { + var exitCode = await ListBackupsAsync(serviceProvider, configPath, verbose, quiet); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption); + + return command; + } + + /// + /// Creates the backup restore command. + /// + public static Command CreateRestoreCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("restore", "Restore a specific backup"); + + var timestampArgument = new Argument("timestamp", "The backup timestamp (e.g., 2026-01-15_120000)"); + command.AddArgument(timestampArgument); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string timestamp) => + { + var exitCode = await RestoreBackupAsync(serviceProvider, configPath, verbose, quiet, timestamp); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, timestampArgument); + + return command; + } + + /// + /// Creates the backup cleanup command. + /// + public static Command CreateCleanupCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("cleanup", "Remove old backups"); + + var keepOption = new Option( + aliases: ["--keep", "-k"], + getDefaultValue: () => 10, + description: "Number of backups to keep"); + command.AddOption(keepOption); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, int keep) => + { + var exitCode = await CleanupBackupsAsync(serviceProvider, configPath, verbose, quiet, keep); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, keepOption); + + return command; + } + + private static async Task CreateBackupAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var backupService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var backupPath = await backupService.CreateBackupAsync(appSettingsPath); + + if (!quiet) + { + Console.WriteLine("Backup created successfully"); + Console.WriteLine($"Path: {backupPath}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task ListBackupsAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var backupService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + try + { + var backups = await backupService.GetBackupsAsync(appSettingsPath); + + if (!quiet) + { + Console.WriteLine($"=== Backups ({backups.Count}) ==="); + } + + if (backups.Count == 0) + { + if (!quiet) + Console.WriteLine("No backups found"); + return 0; + } + + if (!quiet) + { + Console.WriteLine($"{"Timestamp",-20} {"Size",-12} {"Path"}"); + Console.WriteLine(new string('-', 60)); + } + + foreach (var backup in backups) + { + var timestamp = backup.Timestamp.ToString("yyyy-MM-dd_HHmmss"); + var size = FormatFileSize(backup.Size); + var path = verbose ? backup.Path : Path.GetFileName(backup.Path); + + Console.WriteLine($"{timestamp,-20} {size,-12} {path}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task RestoreBackupAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string timestamp) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var backupService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + try + { + var backups = await backupService.GetBackupsAsync(appSettingsPath); + var backup = backups.FirstOrDefault(b => b.Path.Contains(timestamp)); + + if (backup == null) + { + Console.Error.WriteLine($"Error: Backup with timestamp '{timestamp}' not found"); + + if (backups.Count > 0) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("Available backups:"); + foreach (var b in backups.Take(5)) + { + Console.Error.WriteLine($" {b.Timestamp:yyyy-MM-dd_HHmmss}"); + } + } + + return 1; + } + + // Create a backup of current config before restoring + if (File.Exists(appSettingsPath)) + { + var currentBackup = await backupService.CreateBackupAsync(appSettingsPath); + if (!quiet) + { + Console.WriteLine($"Current config backed up to: {Path.GetFileName(currentBackup)}"); + } + } + + await backupService.RestoreBackupAsync(backup.Path, appSettingsPath); + + if (!quiet) + { + Console.WriteLine("Backup restored successfully"); + Console.WriteLine($"Restored from: {Path.GetFileName(backup.Path)}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task CleanupBackupsAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + int keep) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + if (keep < 1) + { + Console.Error.WriteLine("Error: --keep must be at least 1"); + return 1; + } + + var backupService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + try + { + var backupsBefore = await backupService.GetBackupsAsync(appSettingsPath); + var countBefore = backupsBefore.Count; + + await backupService.CleanupOldBackupsAsync(appSettingsPath, keep); + + var backupsAfter = await backupService.GetBackupsAsync(appSettingsPath); + var deleted = countBefore - backupsAfter.Count; + + if (!quiet) + { + if (deleted > 0) + { + Console.WriteLine($"Deleted {deleted} old backup(s)"); + Console.WriteLine($"Remaining: {backupsAfter.Count} backup(s)"); + } + else + { + Console.WriteLine("No backups to delete"); + Console.WriteLine($"Current count: {countBefore} (keeping {keep})"); + } + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static string FormatFileSize(long bytes) + { + string[] sizes = ["B", "KB", "MB", "GB"]; + var order = 0; + double size = bytes; + + while (size >= 1024 && order < sizes.Length - 1) + { + order++; + size /= 1024; + } + + return $"{size:0.##} {sizes[order]}"; + } + + private static async Task GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath) + { + if (!string.IsNullOrEmpty(configPath)) + { + if (Directory.Exists(configPath)) + return configPath; + return null; + } + + var autoDiscoveryService = serviceProvider.GetRequiredService(); + return await autoDiscoveryService.FindConfigFolderAsync(); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/ConfigCommands.cs b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/ConfigCommands.cs new file mode 100644 index 0000000..9e412fd --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/ConfigCommands.cs @@ -0,0 +1,435 @@ +using System.CommandLine; +using System.Text.Json; +using JdeScoping.ConfigManager.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Commands; + +/// +/// Configuration viewing command implementations. +/// +public static class ConfigCommands +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Creates the config show command with section subcommands. + /// + public static Command CreateShowCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("show", "View configuration settings"); + + var jsonOption = new Option( + aliases: ["--json", "-j"], + description: "Output in JSON format"); + + command.AddOption(jsonOption); + + // Add section subcommands + command.AddCommand(CreateSectionCommand("datasync", "Show DataSync configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.DataSync, FormatDataSync)); + + command.AddCommand(CreateSectionCommand("dataaccess", "Show DataAccess configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.DataAccess, FormatDataAccess)); + + command.AddCommand(CreateSectionCommand("auth", "Show Auth configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.Auth, FormatAuth)); + + command.AddCommand(CreateSectionCommand("ldap", "Show LDAP configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.Ldap, FormatLdap)); + + command.AddCommand(CreateSectionCommand("search", "Show Search configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.Search, FormatSearch)); + + command.AddCommand(CreateSectionCommand("excelexport", "Show ExcelExport configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.ExcelExport, FormatExcelExport)); + + command.AddCommand(CreateSectionCommand("securestore", "Show SecureStore configuration", + serviceProvider, configPathOption, verboseOption, quietOption, jsonOption, + config => config.SecureStore, FormatSecureStore)); + + command.AddCommand(CreateConnectionsCommand(serviceProvider, configPathOption, verboseOption, quietOption, jsonOption)); + command.AddCommand(CreateAllCommand(serviceProvider, configPathOption, verboseOption, quietOption, jsonOption)); + + return command; + } + + private static Command CreateSectionCommand( + string name, + string description, + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption, + Option jsonOption, + Func selector, + Action formatter) + { + var command = new Command(name, description); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, bool json) => + { + var exitCode = await ShowSectionAsync(serviceProvider, configPath, verbose, quiet, json, name, selector, formatter); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, jsonOption); + + return command; + } + + private static Command CreateConnectionsCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption, + Option jsonOption) + { + var command = new Command("connections", "Show connection names (no secrets)"); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, bool json) => + { + var exitCode = await ShowConnectionsAsync(serviceProvider, configPath, verbose, quiet, json); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, jsonOption); + + return command; + } + + private static Command CreateAllCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption, + Option jsonOption) + { + var command = new Command("all", "Show all configuration sections"); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, bool json) => + { + var exitCode = await ShowAllAsync(serviceProvider, configPath, verbose, quiet, json); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, jsonOption); + + return command; + } + + private static async Task ShowSectionAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + bool json, + string sectionName, + Func selector, + Action formatter) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + var section = selector(config); + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(section, JsonOptions)); + } + else + { + if (!quiet) + { + Console.WriteLine($"=== {sectionName} ==="); + } + formatter(section, verbose); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task ShowConnectionsAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + bool json) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + + if (json) + { + // Output only names and providers, no secrets + var safeConnections = config.ConnectionStrings.Entries.Select(e => new + { + e.Name, + Provider = e.Provider.ToString() + }); + Console.WriteLine(JsonSerializer.Serialize(safeConnections, JsonOptions)); + } + else + { + if (!quiet) + { + Console.WriteLine($"=== Connections ({config.ConnectionStrings.Entries.Count}) ==="); + Console.WriteLine($"{"Name",-25} {"Provider",-15}"); + Console.WriteLine(new string('-', 40)); + } + + foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name)) + { + Console.WriteLine($"{entry.Name,-25} {entry.Provider,-15}"); + } + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task ShowAllAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + bool json) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + + if (json) + { + // Sanitize passwords before outputting + var sanitizedConfig = new + { + config.DataSync, + config.DataAccess, + config.Auth, + config.Ldap, + config.Search, + config.ExcelExport, + config.SecureStore, + config.Pipelines, + Connections = config.ConnectionStrings.Entries.Select(e => new + { + e.Name, + Provider = e.Provider.ToString() + }) + }; + Console.WriteLine(JsonSerializer.Serialize(sanitizedConfig, JsonOptions)); + } + else + { + Console.WriteLine("=== DataSync ==="); + FormatDataSync(config.DataSync, verbose); + Console.WriteLine(); + + Console.WriteLine("=== DataAccess ==="); + FormatDataAccess(config.DataAccess, verbose); + Console.WriteLine(); + + Console.WriteLine("=== Auth ==="); + FormatAuth(config.Auth, verbose); + Console.WriteLine(); + + Console.WriteLine("=== LDAP ==="); + FormatLdap(config.Ldap, verbose); + Console.WriteLine(); + + Console.WriteLine("=== Search ==="); + FormatSearch(config.Search, verbose); + Console.WriteLine(); + + Console.WriteLine("=== ExcelExport ==="); + FormatExcelExport(config.ExcelExport, verbose); + Console.WriteLine(); + + Console.WriteLine("=== SecureStore ==="); + FormatSecureStore(config.SecureStore, verbose); + Console.WriteLine(); + + Console.WriteLine($"=== Connections ({config.ConnectionStrings.Entries.Count}) ==="); + Console.WriteLine($"{"Name",-25} {"Provider",-15}"); + Console.WriteLine(new string('-', 40)); + foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name)) + { + Console.WriteLine($"{entry.Name,-25} {entry.Provider,-15}"); + } + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static void FormatDataSync(Core.Models.DataSyncSection section, bool verbose) + { + Console.WriteLine($"Enabled: {section.Enabled}"); + Console.WriteLine($"CheckInterval: {section.CheckInterval}"); + Console.WriteLine($"MaxDegreeOfParallelism: {section.MaxDegreeOfParallelism}"); + Console.WriteLine($"BatchSize: {section.BatchSize}"); + Console.WriteLine($"BulkCopyBatchSize: {section.BulkCopyBatchSize}"); + + if (verbose) + { + Console.WriteLine($"LookbackMultiplier: {section.LookbackMultiplier}"); + Console.WriteLine($"PurgeRetentionDays: {section.PurgeRetentionDays}"); + Console.WriteLine($"SyncTimeoutSeconds: {section.SyncTimeoutSeconds}"); + } + } + + private static void FormatDataAccess(Core.Models.DataAccessSection section, bool verbose) + { + Console.WriteLine($"DefaultTimeoutSeconds: {section.DefaultTimeoutSeconds}"); + Console.WriteLine($"LotUsageTimeoutSeconds: {section.LotUsageTimeoutSeconds}"); + Console.WriteLine($"MisDataTimeoutSeconds: {section.MisDataTimeoutSeconds}"); + Console.WriteLine($"EnableDetailedLogging: {section.EnableDetailedLogging}"); + + if (verbose) + { + Console.WriteLine($"ProductionSchema: {section.ProductionSchema}"); + Console.WriteLine($"ArchiveSchema: {section.ArchiveSchema}"); + Console.WriteLine($"StageSchema: {section.StageSchema}"); + } + } + + private static void FormatAuth(Core.Models.AuthSection section, bool verbose) + { + Console.WriteLine($"CookieName: {section.CookieName}"); + Console.WriteLine($"CookieExpirationMinutes: {section.CookieExpirationMinutes}"); + } + + private static void FormatLdap(Core.Models.LdapSection section, bool verbose) + { + Console.WriteLine($"UseFakeAuth: {section.UseFakeAuth}"); + Console.WriteLine($"ConnectionTimeoutSeconds: {section.ConnectionTimeoutSeconds}"); + Console.WriteLine($"ServerUrls: {(section.ServerUrls.Length > 0 ? string.Join(", ", section.ServerUrls) : "(none)")}"); + Console.WriteLine($"GroupDn: {(string.IsNullOrEmpty(section.GroupDn) ? "(not set)" : section.GroupDn)}"); + + if (verbose) + { + Console.WriteLine($"SearchBase: {(string.IsNullOrEmpty(section.SearchBase) ? "(not set)" : section.SearchBase)}"); + Console.WriteLine($"AdminBypassUsers: {(section.AdminBypassUsers.Length > 0 ? string.Join(", ", section.AdminBypassUsers) : "(none)")}"); + } + } + + private static void FormatSearch(Core.Models.SearchSection section, bool verbose) + { + Console.WriteLine($"MaxResultRows: {section.MaxResultRows}"); + Console.WriteLine($"TimeoutSeconds: {section.TimeoutSeconds}"); + Console.WriteLine($"MaxConcurrentSearches: {section.MaxConcurrentSearches}"); + } + + private static void FormatExcelExport(Core.Models.ExcelExportSection section, bool verbose) + { + Console.WriteLine($"MaxRowsPerSheet: {section.MaxRowsPerSheet}"); + Console.WriteLine($"DefaultDateFormat: {section.DefaultDateFormat}"); + Console.WriteLine($"TimezoneId: {section.TimezoneId}"); + Console.WriteLine($"TimezoneAbbreviation: {section.TimezoneAbbreviation}"); + Console.WriteLine($"DebugWriteToFile: {section.DebugWriteToFile}"); + + if (verbose && section.DebugWriteToFile) + { + Console.WriteLine($"DebugOutputDirectory: {section.DebugOutputDirectory}"); + } + + // Note: passwords are not shown + Console.WriteLine("CriteriaSheetPassword: ********"); + Console.WriteLine("DataSheetPassword: ********"); + } + + private static void FormatSecureStore(Core.Models.SecureStoreSection section, bool verbose) + { + Console.WriteLine($"StorePath: {section.StorePath}"); + Console.WriteLine($"KeyFilePath: {section.KeyFilePath}"); + Console.WriteLine($"AutoCreateStore: {section.AutoCreateStore}"); + Console.WriteLine($"RequiredKeys: {(section.RequiredKeys.Count > 0 ? string.Join(", ", section.RequiredKeys) : "(none)")}"); + } + + private static async Task GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath) + { + if (!string.IsNullOrEmpty(configPath)) + { + if (Directory.Exists(configPath)) + return configPath; + return null; + } + + var autoDiscoveryService = serviceProvider.GetRequiredService(); + return await autoDiscoveryService.FindConfigFolderAsync(); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/ConnectionCommands.cs b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/ConnectionCommands.cs new file mode 100644 index 0000000..eed3d30 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/ConnectionCommands.cs @@ -0,0 +1,492 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Core.Models; +using JdeScoping.ConfigManager.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Commands; + +/// +/// Connection string management command implementations. +/// +public static class ConnectionCommands +{ + /// + /// Creates the connection list command. + /// + public static Command CreateListCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("list", "List all connection names and providers"); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet) => + { + var exitCode = await ListConnectionsAsync(serviceProvider, configPath, verbose, quiet); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption); + + return command; + } + + /// + /// Creates the connection show command. + /// + public static Command CreateShowCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("show", "Show connection details (password masked)"); + + var nameArgument = new Argument("name", "The connection name"); + command.AddArgument(nameArgument); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name) => + { + var exitCode = await ShowConnectionAsync(serviceProvider, configPath, verbose, quiet, name); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument); + + return command; + } + + /// + /// Creates the connection add command. + /// + public static Command CreateAddCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("add", "Add new connection"); + + var nameArgument = new Argument("name", "The connection name"); + command.AddArgument(nameArgument); + + var providerOption = new Option( + aliases: ["--provider", "-p"], + getDefaultValue: () => ConnectionProvider.SqlServer, + description: "Database provider (SqlServer, Oracle, Generic)"); + command.AddOption(providerOption); + + var serverOption = new Option( + aliases: ["--server", "-s"], + description: "Server name or host"); + command.AddOption(serverOption); + + var databaseOption = new Option( + aliases: ["--database", "-d"], + description: "Database name"); + command.AddOption(databaseOption); + + var userOption = new Option( + aliases: ["--user", "-u"], + description: "User ID"); + command.AddOption(userOption); + + var passwordOption = new Option( + aliases: ["--password"], + description: "Password (will be stored in config)"); + command.AddOption(passwordOption); + + var portOption = new Option( + aliases: ["--port"], + description: "Port number"); + command.AddOption(portOption); + + var serviceNameOption = new Option( + aliases: ["--service-name"], + description: "Oracle service name"); + command.AddOption(serviceNameOption); + + var rawOption = new Option( + aliases: ["--raw"], + description: "Raw connection string (for Generic provider)"); + command.AddOption(rawOption); + + command.SetHandler(async (context) => + { + var configPath = context.ParseResult.GetValueForOption(configPathOption); + var verbose = context.ParseResult.GetValueForOption(verboseOption); + var quiet = context.ParseResult.GetValueForOption(quietOption); + var name = context.ParseResult.GetValueForArgument(nameArgument); + var provider = context.ParseResult.GetValueForOption(providerOption); + var server = context.ParseResult.GetValueForOption(serverOption); + var database = context.ParseResult.GetValueForOption(databaseOption); + var user = context.ParseResult.GetValueForOption(userOption); + var password = context.ParseResult.GetValueForOption(passwordOption); + var port = context.ParseResult.GetValueForOption(portOption); + var serviceName = context.ParseResult.GetValueForOption(serviceNameOption); + var raw = context.ParseResult.GetValueForOption(rawOption); + + var exitCode = await AddConnectionAsync( + serviceProvider, configPath, verbose, quiet, + name, provider, server, database, user, password, port, serviceName, raw); + Environment.ExitCode = exitCode; + }); + + return command; + } + + /// + /// Creates the connection remove command. + /// + public static Command CreateRemoveCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("remove", "Remove a connection"); + + var nameArgument = new Argument("name", "The connection name to remove"); + command.AddArgument(nameArgument); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name) => + { + var exitCode = await RemoveConnectionAsync(serviceProvider, configPath, verbose, quiet, name); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument); + + return command; + } + + private static async Task ListConnectionsAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + + if (!quiet) + { + Console.WriteLine($"=== Connections ({config.ConnectionStrings.Entries.Count}) ==="); + Console.WriteLine($"{"Name",-25} {"Provider",-15} {"Server/Host",-30}"); + Console.WriteLine(new string('-', 70)); + } + + foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name)) + { + var serverOrHost = entry.Provider switch + { + ConnectionProvider.SqlServer => entry.Server ?? "-", + ConnectionProvider.Oracle => entry.Host ?? "-", + ConnectionProvider.Generic => "(raw)", + _ => "-" + }; + + Console.WriteLine($"{entry.Name,-25} {entry.Provider,-15} {serverOrHost,-30}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task ShowConnectionAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + var entry = config.ConnectionStrings.Entries.FirstOrDefault(e => + e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (entry == null) + { + Console.Error.WriteLine($"Error: Connection '{name}' not found"); + return 1; + } + + if (!quiet) + { + Console.WriteLine($"=== Connection: {entry.Name} ==="); + } + + Console.WriteLine($"Name: {entry.Name}"); + Console.WriteLine($"Provider: {entry.Provider}"); + + switch (entry.Provider) + { + case ConnectionProvider.SqlServer: + Console.WriteLine($"Server: {entry.Server ?? "(not set)"}"); + if (entry.SqlServerPort.HasValue) + Console.WriteLine($"Port: {entry.SqlServerPort.Value}"); + Console.WriteLine($"Database: {entry.Database ?? "(not set)"}"); + Console.WriteLine($"User ID: {entry.UserId ?? "(not set)"}"); + Console.WriteLine($"Password: {MaskPassword(entry.Password)}"); + Console.WriteLine($"Encrypt: {entry.Encrypt}"); + Console.WriteLine($"TrustServerCertificate: {entry.TrustServerCertificate}"); + Console.WriteLine($"ConnectionTimeout: {entry.ConnectionTimeout}"); + if (!string.IsNullOrEmpty(entry.ApplicationName)) + Console.WriteLine($"ApplicationName: {entry.ApplicationName}"); + break; + + case ConnectionProvider.Oracle: + Console.WriteLine($"Host: {entry.Host ?? "(not set)"}"); + Console.WriteLine($"Port: {entry.Port}"); + Console.WriteLine($"ServiceName: {entry.ServiceName ?? "(not set)"}"); + Console.WriteLine($"User ID: {entry.UserId ?? "(not set)"}"); + Console.WriteLine($"Password: {MaskPassword(entry.Password)}"); + break; + + case ConnectionProvider.Generic: + Console.WriteLine($"RawConnectionString: {MaskConnectionString(entry.RawConnectionString)}"); + break; + } + + if (verbose) + { + Console.WriteLine(); + Console.WriteLine("Generated connection string (masked):"); + Console.WriteLine($" {MaskConnectionString(entry.GenerateConnectionString())}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task AddConnectionAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name, + ConnectionProvider provider, + string? server, + string? database, + string? user, + string? password, + int? port, + string? serviceName, + string? raw) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + + // Check if connection already exists + if (config.ConnectionStrings.Entries.Any(e => + e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + Console.Error.WriteLine($"Error: Connection '{name}' already exists"); + return 1; + } + + // Validate required fields based on provider + if (provider == ConnectionProvider.Generic && string.IsNullOrEmpty(raw)) + { + Console.Error.WriteLine("Error: --raw is required for Generic provider"); + return 1; + } + + var entry = new ConnectionStringEntry + { + Name = name, + Provider = provider, + Server = server, + Database = database, + UserId = user, + Password = password, + Host = server, // For Oracle, use server as host + ServiceName = serviceName, + RawConnectionString = raw + }; + + if (port.HasValue) + { + if (provider == ConnectionProvider.SqlServer) + entry.SqlServerPort = port; + else if (provider == ConnectionProvider.Oracle) + entry.Port = port.Value; + } + + config.ConnectionStrings.Entries.Add(entry); + await configFileService.SaveAppSettingsAsync(appSettingsPath, config); + + if (!quiet) + { + Console.WriteLine($"Connection '{name}' added successfully"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task RemoveConnectionAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); + + if (!File.Exists(appSettingsPath)) + { + Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}"); + return 1; + } + + try + { + var config = await configFileService.LoadAppSettingsAsync(appSettingsPath); + var entry = config.ConnectionStrings.Entries.FirstOrDefault(e => + e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (entry == null) + { + Console.Error.WriteLine($"Error: Connection '{name}' not found"); + return 1; + } + + config.ConnectionStrings.Entries.Remove(entry); + await configFileService.SaveAppSettingsAsync(appSettingsPath, config); + + if (!quiet) + { + Console.WriteLine($"Connection '{name}' removed successfully"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static string MaskPassword(string? password) + { + if (string.IsNullOrEmpty(password)) + return "(not set)"; + return "********"; + } + + private static string MaskConnectionString(string? connectionString) + { + if (string.IsNullOrEmpty(connectionString)) + return "(not set)"; + + // Mask password in connection string + var result = connectionString; + + // Common patterns for password in connection strings + var patterns = new[] + { + "Password=", + "Pwd=", + "password=" + }; + + foreach (var pattern in patterns) + { + var idx = result.IndexOf(pattern, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var start = idx + pattern.Length; + var end = result.IndexOf(';', start); + if (end < 0) end = result.Length; + + result = string.Concat(result.AsSpan(0, start), "********", result.AsSpan(end)); + } + } + + return result; + } + + private static async Task GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath) + { + if (!string.IsNullOrEmpty(configPath)) + { + if (Directory.Exists(configPath)) + return configPath; + return null; + } + + var autoDiscoveryService = serviceProvider.GetRequiredService(); + return await autoDiscoveryService.FindConfigFolderAsync(); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/PipelineCommands.cs b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/PipelineCommands.cs new file mode 100644 index 0000000..5d016e6 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Commands/PipelineCommands.cs @@ -0,0 +1,487 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Core.Services; +using JdeScoping.DataSync.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Commands; + +/// +/// Pipeline management command implementations. +/// +public static class PipelineCommands +{ + /// + /// Creates the pipeline list command. + /// + public static Command CreateListCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("list", "List all pipelines with status"); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet) => + { + var exitCode = await ListPipelinesAsync(serviceProvider, configPath, verbose, quiet); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption); + + return command; + } + + /// + /// Creates the pipeline show command. + /// + public static Command CreateShowCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("show", "Show detailed pipeline configuration"); + + var nameArgument = new Argument("name", "The pipeline name"); + command.AddArgument(nameArgument); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name) => + { + var exitCode = await ShowPipelineAsync(serviceProvider, configPath, verbose, quiet, name); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument); + + return command; + } + + /// + /// Creates the pipeline create command. + /// + public static Command CreateCreateCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("create", "Create new pipeline with defaults"); + + var nameArgument = new Argument("name", "The pipeline name"); + command.AddArgument(nameArgument); + + var enabledOption = new Option( + aliases: ["--enabled", "-e"], + getDefaultValue: () => true, + description: "Whether the pipeline is enabled"); + command.AddOption(enabledOption); + + var manualOnlyOption = new Option( + aliases: ["--manual-only", "-m"], + description: "Pipeline can only be triggered manually"); + command.AddOption(manualOnlyOption); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name, bool enabled, bool manualOnly) => + { + var exitCode = await CreatePipelineAsync(serviceProvider, configPath, verbose, quiet, name, enabled, manualOnly); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument, enabledOption, manualOnlyOption); + + return command; + } + + /// + /// Creates the pipeline delete command. + /// + public static Command CreateDeleteCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("delete", "Delete a pipeline"); + + var nameArgument = new Argument("name", "The pipeline name to delete"); + command.AddArgument(nameArgument); + + var forceOption = new Option( + aliases: ["--force", "-f"], + description: "Skip confirmation prompt"); + command.AddOption(forceOption); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name, bool force) => + { + var exitCode = await DeletePipelineAsync(serviceProvider, configPath, verbose, quiet, name, force); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument, forceOption); + + return command; + } + + /// + /// Creates the pipeline enable command. + /// + public static Command CreateEnableCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("enable", "Enable a pipeline"); + + var nameArgument = new Argument("name", "The pipeline name to enable"); + command.AddArgument(nameArgument); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name) => + { + var exitCode = await SetPipelineEnabledAsync(serviceProvider, configPath, verbose, quiet, name, true); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument); + + return command; + } + + /// + /// Creates the pipeline disable command. + /// + public static Command CreateDisableCommand( + IServiceProvider serviceProvider, + Option configPathOption, + Option verboseOption, + Option quietOption) + { + var command = new Command("disable", "Disable a pipeline"); + + var nameArgument = new Argument("name", "The pipeline name to disable"); + command.AddArgument(nameArgument); + + command.SetHandler(async (string? configPath, bool verbose, bool quiet, string name) => + { + var exitCode = await SetPipelineEnabledAsync(serviceProvider, configPath, verbose, quiet, name, false); + Environment.ExitCode = exitCode; + }, configPathOption, verboseOption, quietOption, nameArgument); + + return command; + } + + private static async Task ListPipelinesAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var pipelinesDir = Path.Combine(folderPath, "Pipelines"); + + try + { + var pipelines = await configFileService.LoadAllPipelinesAsync(pipelinesDir); + + if (!quiet) + { + Console.WriteLine($"=== Pipelines ({pipelines.Count}) ==="); + Console.WriteLine($"{"Name",-20} {"Enabled",-8} {"Mass",-8} {"Daily",-8} {"Hourly",-8}"); + Console.WriteLine(new string('-', 52)); + } + + foreach (var kvp in pipelines.OrderBy(p => p.Key)) + { + var p = kvp.Value; + var mass = FormatInterval(p.MassSyncIntervalMinutes); + var daily = FormatInterval(p.DailySyncIntervalMinutes); + var hourly = FormatInterval(p.HourlySyncIntervalMinutes); + var enabled = p.IsEnabled ? "Yes" : "No"; + + Console.WriteLine($"{p.Name,-20} {enabled,-8} {mass,-8} {daily,-8} {hourly,-8}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task ShowPipelineAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var pipelinePath = Path.Combine(folderPath, "Pipelines", $"pipeline.{name}.json"); + + if (!File.Exists(pipelinePath)) + { + Console.Error.WriteLine($"Error: Pipeline '{name}' not found"); + return 1; + } + + try + { + var pipeline = await configFileService.LoadPipelineAsync(pipelinePath); + + if (!quiet) + { + Console.WriteLine($"=== Pipeline: {pipeline.Name} ==="); + } + + Console.WriteLine($"Name: {pipeline.Name}"); + Console.WriteLine($"Enabled: {pipeline.IsEnabled}"); + Console.WriteLine($"Manual Only: {pipeline.IsManualOnly}"); + Console.WriteLine(); + + Console.WriteLine("Sync Intervals:"); + Console.WriteLine($" Mass: {FormatInterval(pipeline.MassSyncIntervalMinutes)}"); + Console.WriteLine($" Daily: {FormatInterval(pipeline.DailySyncIntervalMinutes)}"); + Console.WriteLine($" Hourly: {FormatInterval(pipeline.HourlySyncIntervalMinutes)}"); + + if (pipeline.Source != null) + { + Console.WriteLine(); + Console.WriteLine("Source:"); + Console.WriteLine($" Connection: {pipeline.Source.Connection}"); + if (!string.IsNullOrEmpty(pipeline.Source.Query)) + Console.WriteLine($" Query: {(pipeline.Source.Query.Length > 50 ? pipeline.Source.Query[..50] + "..." : pipeline.Source.Query)}"); + } + + if (pipeline.Destination != null) + { + Console.WriteLine(); + Console.WriteLine("Destination:"); + Console.WriteLine($" Table: {pipeline.Destination.Table}"); + if (pipeline.Destination.MatchColumns.Count > 0) + Console.WriteLine($" Match Columns: {string.Join(", ", pipeline.Destination.MatchColumns)}"); + } + + if (verbose) + { + if (pipeline.PreScripts.Count > 0) + { + Console.WriteLine(); + Console.WriteLine($"Pre-Scripts: {pipeline.PreScripts.Count}"); + } + + if (pipeline.Transforms.Count > 0) + { + Console.WriteLine(); + Console.WriteLine($"Transforms: {pipeline.Transforms.Count}"); + } + + if (pipeline.PostScripts.Count > 0) + { + Console.WriteLine(); + Console.WriteLine($"Post-Scripts: {pipeline.PostScripts.Count}"); + } + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task CreatePipelineAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name, + bool enabled, + bool manualOnly) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var pipelinesDir = Path.Combine(folderPath, "Pipelines"); + var pipelinePath = Path.Combine(pipelinesDir, $"pipeline.{name}.json"); + + if (File.Exists(pipelinePath)) + { + Console.Error.WriteLine($"Error: Pipeline '{name}' already exists"); + return 1; + } + + try + { + // Create Pipelines directory if it doesn't exist + if (!Directory.Exists(pipelinesDir)) + { + Directory.CreateDirectory(pipelinesDir); + } + + var pipeline = new EtlPipelineConfig + { + Name = name, + IsEnabled = enabled, + IsManualOnly = manualOnly, + Source = new SourceElement { Connection = "jde" }, + Destination = new DestinationElement { Table = name } + }; + + await configFileService.SavePipelineAsync(pipelinePath, pipeline); + + if (!quiet) + { + Console.WriteLine($"Pipeline '{name}' created successfully"); + Console.WriteLine($"File: {pipelinePath}"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task DeletePipelineAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name, + bool force) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var pipelinePath = Path.Combine(folderPath, "Pipelines", $"pipeline.{name}.json"); + + if (!File.Exists(pipelinePath)) + { + Console.Error.WriteLine($"Error: Pipeline '{name}' not found"); + return 1; + } + + if (!force) + { + Console.Write($"Delete pipeline '{name}'? [y/N] "); + var response = Console.ReadLine(); + if (!string.Equals(response, "y", StringComparison.OrdinalIgnoreCase) && + !string.Equals(response, "yes", StringComparison.OrdinalIgnoreCase)) + { + if (!quiet) + Console.WriteLine("Cancelled"); + return 0; + } + } + + try + { + await configFileService.DeletePipelineFileAsync(pipelinePath); + + if (!quiet) + { + Console.WriteLine($"Pipeline '{name}' deleted successfully"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task SetPipelineEnabledAsync( + IServiceProvider serviceProvider, + string? configPath, + bool verbose, + bool quiet, + string name, + bool enabled) + { + var folderPath = await GetConfigFolderAsync(serviceProvider, configPath); + if (folderPath == null) + { + Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify."); + return 1; + } + + var configFileService = serviceProvider.GetRequiredService(); + var pipelinePath = Path.Combine(folderPath, "Pipelines", $"pipeline.{name}.json"); + + if (!File.Exists(pipelinePath)) + { + Console.Error.WriteLine($"Error: Pipeline '{name}' not found"); + return 1; + } + + try + { + var pipeline = await configFileService.LoadPipelineAsync(pipelinePath); + pipeline.IsEnabled = enabled; + await configFileService.SavePipelineAsync(pipelinePath, pipeline); + + if (!quiet) + { + var action = enabled ? "enabled" : "disabled"; + Console.WriteLine($"Pipeline '{name}' {action} successfully"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static string FormatInterval(int? minutes) + { + if (!minutes.HasValue) + return "-"; + + if (minutes.Value >= 10080) // 7 days + return $"{minutes.Value / 10080}w"; + if (minutes.Value >= 1440) // 1 day + return $"{minutes.Value / 1440}d"; + if (minutes.Value >= 60) + return $"{minutes.Value / 60}h"; + return $"{minutes.Value}m"; + } + + private static async Task GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath) + { + if (!string.IsNullOrEmpty(configPath)) + { + if (Directory.Exists(configPath)) + return configPath; + return null; + } + + var autoDiscoveryService = serviceProvider.GetRequiredService(); + return await autoDiscoveryService.FindConfigFolderAsync(); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Program.cs b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Program.cs index 8fbc60a..6d1ffdf 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Program.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/Program.cs @@ -68,6 +68,37 @@ public static class Program secretCommand.AddCommand(SecretCommands.CreateInitCommand(serviceProvider, configPathOption, verboseOption, quietOption)); rootCommand.AddCommand(secretCommand); + // Pipeline command group + var pipelineCommand = new Command("pipeline", "Manage ETL pipelines"); + pipelineCommand.AddCommand(PipelineCommands.CreateListCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + pipelineCommand.AddCommand(PipelineCommands.CreateShowCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + pipelineCommand.AddCommand(PipelineCommands.CreateCreateCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + pipelineCommand.AddCommand(PipelineCommands.CreateDeleteCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + pipelineCommand.AddCommand(PipelineCommands.CreateEnableCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + pipelineCommand.AddCommand(PipelineCommands.CreateDisableCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + rootCommand.AddCommand(pipelineCommand); + + // Config command group + var configCommand = new Command("config", "View configuration settings"); + configCommand.AddCommand(ConfigCommands.CreateShowCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + rootCommand.AddCommand(configCommand); + + // Backup command group + var backupCommand = new Command("backup", "Manage configuration backups"); + backupCommand.AddCommand(BackupCommands.CreateCreateCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + backupCommand.AddCommand(BackupCommands.CreateListCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + backupCommand.AddCommand(BackupCommands.CreateRestoreCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + backupCommand.AddCommand(BackupCommands.CreateCleanupCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + rootCommand.AddCommand(backupCommand); + + // Connection command group + var connectionCommand = new Command("connection", "Manage connection strings"); + connectionCommand.AddCommand(ConnectionCommands.CreateListCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + connectionCommand.AddCommand(ConnectionCommands.CreateShowCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + connectionCommand.AddCommand(ConnectionCommands.CreateAddCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + connectionCommand.AddCommand(ConnectionCommands.CreateRemoveCommand(serviceProvider, configPathOption, verboseOption, quietOption)); + rootCommand.AddCommand(connectionCommand); + return await rootCommand.InvokeAsync(args); } diff --git a/NEW/src/Utils/JdeScoping.ConfigManager.Cli/README.md b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/README.md new file mode 100644 index 0000000..bb8edf5 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager.Cli/README.md @@ -0,0 +1,273 @@ +# JdeScoping ConfigManager CLI + +Command-line tool for managing JDE Scoping Tool configuration files, ETL pipelines, connection strings, secrets, and backups. + +## Installation + +Build the tool from the solution: + +```bash +dotnet build NEW/src/Utils/JdeScoping.ConfigManager.Cli/JdeScoping.ConfigManager.Cli.csproj +``` + +The executable is located at: +- **Windows:** `bin/Debug/net10.0/jdescoping-config.exe` +- **macOS/Linux:** `bin/Debug/net10.0/jdescoping-config` + +## Global Options + +These options apply to all commands: + +| Option | Alias | Description | +|--------|-------|-------------| +| `--config-path` | `-c` | Path to configuration folder (overrides auto-discovery) | +| `--verbose` | `-v` | Enable verbose output | +| `--quiet` | `-q` | Suppress non-error output | + +## Configuration Discovery + +If `--config-path` is not specified, the tool searches for configuration in this order: + +1. `JDESCOPING_CONFIG_PATH` environment variable +2. Same directory as the executable +3. `../JdeScoping.Host/` relative to executable +4. User config directory (`~/.jdescoping` on Unix, `%LOCALAPPDATA%\JdeScoping` on Windows) + +## Commands Reference + +### Validation Commands + +Validate configuration files for errors and warnings. + +```bash +# Validate appsettings.json +jdescoping-config validate appsettings + +# Validate all pipeline files +jdescoping-config validate pipelines + +# Validate all configuration files +jdescoping-config validate all + +# Run runtime infrastructure validators +jdescoping-config validate runtime +``` + +### Connection Testing Commands + +Test database connections defined in configuration. + +```bash +# Test SQL Server connection +jdescoping-config test-connection sql + +# Test a specific named connection +jdescoping-config test-connection sql --name SqlServerCache + +# Test Oracle connection +jdescoping-config test-connection oracle + +# Test all configured connections +jdescoping-config test-connection all +``` + +### Secret Management Commands + +Manage encrypted secrets in SecureStore. + +```bash +# Initialize a new SecureStore +jdescoping-config secret init + +# List all secret keys +jdescoping-config secret list + +# Get a secret value +jdescoping-config secret get + +# Set or update a secret +jdescoping-config secret set + +# Remove a secret +jdescoping-config secret remove +``` + +### Pipeline Management Commands + +Manage ETL pipeline configurations. + +```bash +# List all pipelines with status +jdescoping-config pipeline list + +# Example output: +# === Pipelines (3) === +# Name Enabled Mass Daily Hourly +# -------------------- -------- -------- -------- -------- +# WorkOrder_Curr Yes 1w 1d 1h +# LotUsage_Curr Yes 1w 1d - +# Item No 1w - - + +# Show detailed pipeline configuration +jdescoping-config pipeline show + +# Create a new pipeline with defaults +jdescoping-config pipeline create +jdescoping-config pipeline create --enabled false +jdescoping-config pipeline create --manual-only + +# Delete a pipeline +jdescoping-config pipeline delete +jdescoping-config pipeline delete --force # Skip confirmation + +# Enable/disable a pipeline +jdescoping-config pipeline enable +jdescoping-config pipeline disable +``` + +### Configuration Viewing Commands + +View configuration settings (read-only). + +```bash +# Show specific sections +jdescoping-config config show datasync +jdescoping-config config show dataaccess +jdescoping-config config show auth +jdescoping-config config show ldap +jdescoping-config config show search +jdescoping-config config show excelexport +jdescoping-config config show securestore + +# Show connection names (no secrets) +jdescoping-config config show connections + +# Show all configuration sections +jdescoping-config config show all + +# Output as JSON +jdescoping-config config show datasync --json +jdescoping-config config show all --json +``` + +### Backup Management Commands + +Manage configuration file backups. + +```bash +# Create a backup of appsettings.json +jdescoping-config backup create + +# List available backups +jdescoping-config backup list + +# Example output: +# === Backups (3) === +# Timestamp Size Path +# -------------------- ------------ -------------------------------- +# 2026-01-15_120000 1.2 KB appsettings.2026-01-15_120000.bak +# 2026-01-14_090000 1.1 KB appsettings.2026-01-14_090000.bak + +# Restore a specific backup +jdescoping-config backup restore +jdescoping-config backup restore 2026-01-15_120000 + +# Remove old backups (keep most recent N) +jdescoping-config backup cleanup +jdescoping-config backup cleanup --keep 5 +``` + +### Connection String Management Commands + +Manage database connection strings. + +```bash +# List all connections +jdescoping-config connection list + +# Example output: +# === Connections (3) === +# Name Provider Server/Host +# ------------------------- --------------- ------------------------------ +# SqlServerCache SqlServer localhost +# JdeOracle Oracle jde-prod-server +# CmsSybase Generic (raw) + +# Show connection details (password masked) +jdescoping-config connection show +jdescoping-config connection show SqlServerCache + +# Add a new SQL Server connection +jdescoping-config connection add MyConnection \ + --provider SqlServer \ + --server localhost \ + --database MyDatabase \ + --user sa \ + --password secret123 \ + --port 1434 + +# Add a new Oracle connection +jdescoping-config connection add JdeOracle \ + --provider Oracle \ + --server oracle-host \ + --service-name JDEPRD \ + --port 1521 \ + --user jdeuser + +# Add a Generic connection (raw connection string) +jdescoping-config connection add CustomDb \ + --provider Generic \ + --raw "Driver={...};Server=...;Database=..." + +# Remove a connection +jdescoping-config connection remove +``` + +## Exit Codes + +| Code | Description | +|------|-------------| +| 0 | Success | +| 1 | Error (validation failure, missing file, operation error) | + +## Examples + +### Validate and test before deployment + +```bash +# Validate all configuration +jdescoping-config validate all + +# Test all database connections +jdescoping-config test-connection all + +# Create a backup before making changes +jdescoping-config backup create +``` + +### Quick status check + +```bash +# View pipelines +jdescoping-config pipeline list --quiet + +# View connections +jdescoping-config connection list --quiet +``` + +### Scripted configuration + +```bash +# Disable a pipeline for maintenance +jdescoping-config pipeline disable WorkOrder_Curr + +# Re-enable after maintenance +jdescoping-config pipeline enable WorkOrder_Curr +``` + +### Export configuration as JSON + +```bash +# Export all config to JSON for review +jdescoping-config config show all --json > config-snapshot.json +``` diff --git a/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/BackupCommandsTests.cs b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/BackupCommandsTests.cs new file mode 100644 index 0000000..2fafe76 --- /dev/null +++ b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/BackupCommandsTests.cs @@ -0,0 +1,298 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Cli.Commands; +using JdeScoping.ConfigManager.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Tests.Commands; + +public class BackupCommandsTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IBackupService _backupService; + private readonly IAutoDiscoveryService _autoDiscoveryService; + private readonly Option _configPathOption; + private readonly Option _verboseOption; + private readonly Option _quietOption; + + public BackupCommandsTests() + { + _backupService = Substitute.For(); + _autoDiscoveryService = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(_backupService); + services.AddSingleton(_autoDiscoveryService); + _serviceProvider = services.BuildServiceProvider(); + + _configPathOption = new Option(["--config-path", "-c"]); + _verboseOption = new Option(["--verbose", "-v"]); + _quietOption = new Option(["--quiet", "-q"]); + } + + [Fact] + public void CreateCreateCommand_ReturnsCommand() + { + // Act + var command = BackupCommands.CreateCreateCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("create"); + } + + [Fact] + public void CreateListCommand_ReturnsCommand() + { + // Act + var command = BackupCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("list"); + } + + [Fact] + public void CreateRestoreCommand_ReturnsCommandWithTimestampArgument() + { + // Act + var command = BackupCommands.CreateRestoreCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("restore"); + command.Arguments.ShouldContain(a => a.Name == "timestamp"); + } + + [Fact] + public void CreateCleanupCommand_ReturnsCommandWithKeepOption() + { + // Act + var command = BackupCommands.CreateCleanupCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("cleanup"); + command.Options.ShouldContain(o => o.Name == "keep"); + } + + [Fact] + public async Task CreateCommand_WithNoConfigFolder_ReturnsError() + { + // Arrange + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(null)); + + var command = BackupCommands.CreateCreateCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Act + var exitCode = await rootCommand.InvokeAsync(["create"]); + + // Assert - command completes without throwing + command.ShouldNotBeNull(); + } + + [Fact] + public async Task CreateCommand_WithValidConfig_CreatesBackup() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + var backupPath = Path.Combine(tempDir, "appsettings.2026-01-15_120000.bak"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + _backupService.CreateBackupAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(backupPath)); + + var command = BackupCommands.CreateCreateCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console output + var originalOut = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["create"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("Backup created successfully"); + await _backupService.Received(1).CreateBackupAsync(appSettingsPath, Arg.Any()); + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ListCommand_HasCorrectDescription() + { + // Arrange & Act + var command = BackupCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.Description.ShouldBe("List available backups"); + } + + [Fact] + public async Task ListCommand_WithNoBackups_ShowsEmptyMessage() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + _backupService.GetBackupsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var command = BackupCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console output + var originalOut = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["list"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("No backups found"); + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task CleanupCommand_CallsCleanupService() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + _backupService.GetBackupsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var command = BackupCommands.CreateCleanupCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Act + await rootCommand.InvokeAsync(["cleanup", "--keep", "5"]); + + // Assert + await _backupService.Received(1).CleanupOldBackupsAsync(appSettingsPath, 5, Arg.Any()); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task RestoreCommand_WithNonExistentBackup_ReturnsError() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + _backupService.GetBackupsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var command = BackupCommands.CreateRestoreCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console error output + var originalErr = Console.Error; + using var writer = new StringWriter(); + Console.SetError(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["restore", "2026-01-99_999999"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("not found"); + } + finally + { + Console.SetError(originalErr); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } +} diff --git a/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/ConfigCommandsTests.cs b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/ConfigCommandsTests.cs new file mode 100644 index 0000000..f944275 --- /dev/null +++ b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/ConfigCommandsTests.cs @@ -0,0 +1,183 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Cli.Commands; +using JdeScoping.ConfigManager.Core.Models; +using JdeScoping.ConfigManager.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Tests.Commands; + +public class ConfigCommandsTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IConfigFileService _configFileService; + private readonly IAutoDiscoveryService _autoDiscoveryService; + private readonly Option _configPathOption; + private readonly Option _verboseOption; + private readonly Option _quietOption; + + public ConfigCommandsTests() + { + _configFileService = Substitute.For(); + _autoDiscoveryService = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(_configFileService); + services.AddSingleton(_autoDiscoveryService); + _serviceProvider = services.BuildServiceProvider(); + + _configPathOption = new Option(["--config-path", "-c"]); + _verboseOption = new Option(["--verbose", "-v"]); + _quietOption = new Option(["--quiet", "-q"]); + } + + [Fact] + public void CreateShowCommand_ReturnsCommandWithSubcommands() + { + // Act + var command = ConfigCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("show"); + command.Subcommands.ShouldContain(c => c.Name == "datasync"); + command.Subcommands.ShouldContain(c => c.Name == "dataaccess"); + command.Subcommands.ShouldContain(c => c.Name == "auth"); + command.Subcommands.ShouldContain(c => c.Name == "ldap"); + command.Subcommands.ShouldContain(c => c.Name == "search"); + command.Subcommands.ShouldContain(c => c.Name == "excelexport"); + command.Subcommands.ShouldContain(c => c.Name == "securestore"); + command.Subcommands.ShouldContain(c => c.Name == "connections"); + command.Subcommands.ShouldContain(c => c.Name == "all"); + } + + [Fact] + public void CreateShowCommand_HasJsonOption() + { + // Act + var command = ConfigCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.Options.ShouldContain(o => o.Name == "json"); + } + + [Fact] + public async Task ShowDataSyncCommand_WithNoConfigFolder_ReturnsError() + { + // Arrange + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(null)); + + var command = ConfigCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Act + var exitCode = await rootCommand.InvokeAsync(["show", "datasync"]); + + // Assert - command completes without throwing + command.ShouldNotBeNull(); + } + + [Fact] + public void DataSyncSubcommand_Exists() + { + // Arrange + var command = ConfigCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Act + var datasyncSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "datasync"); + + // Assert + datasyncSubcommand.ShouldNotBeNull(); + datasyncSubcommand.Description.ShouldBe("Show DataSync configuration"); + } + + [Fact] + public async Task ShowConnectionsCommand_MasksNoPasswords() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var config = new ConfigModel + { + ConnectionStrings = new ConnectionStringsSection + { + Entries = + [ + new ConnectionStringEntry + { + Name = "TestConnection", + Provider = ConnectionProvider.SqlServer, + Server = "localhost", + Password = "secret123" + } + ] + } + }; + + _configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = ConfigCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console output + var originalOut = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["show", "connections"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("TestConnection"); + output.ShouldNotContain("secret123"); // Password should not be visible + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void AllSubcommand_HasJsonOption() + { + // Arrange + var command = ConfigCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Act + var allSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "all"); + + // Assert + allSubcommand.ShouldNotBeNull(); + // The json option is on the parent show command + command.Options.ShouldContain(o => o.Name == "json"); + } +} diff --git a/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/ConnectionCommandsTests.cs b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/ConnectionCommandsTests.cs new file mode 100644 index 0000000..edc8cf3 --- /dev/null +++ b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/ConnectionCommandsTests.cs @@ -0,0 +1,419 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Cli.Commands; +using JdeScoping.ConfigManager.Core.Models; +using JdeScoping.ConfigManager.Core.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Tests.Commands; + +public class ConnectionCommandsTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IConfigFileService _configFileService; + private readonly IAutoDiscoveryService _autoDiscoveryService; + private readonly Option _configPathOption; + private readonly Option _verboseOption; + private readonly Option _quietOption; + + public ConnectionCommandsTests() + { + _configFileService = Substitute.For(); + _autoDiscoveryService = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(_configFileService); + services.AddSingleton(_autoDiscoveryService); + _serviceProvider = services.BuildServiceProvider(); + + _configPathOption = new Option(["--config-path", "-c"]); + _verboseOption = new Option(["--verbose", "-v"]); + _quietOption = new Option(["--quiet", "-q"]); + } + + [Fact] + public void CreateListCommand_ReturnsCommand() + { + // Act + var command = ConnectionCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("list"); + } + + [Fact] + public void CreateShowCommand_ReturnsCommandWithNameArgument() + { + // Act + var command = ConnectionCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("show"); + command.Arguments.ShouldContain(a => a.Name == "name"); + } + + [Fact] + public void CreateAddCommand_ReturnsCommandWithOptions() + { + // Act + var command = ConnectionCommands.CreateAddCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("add"); + command.Arguments.ShouldContain(a => a.Name == "name"); + command.Options.ShouldContain(o => o.Name == "provider"); + command.Options.ShouldContain(o => o.Name == "server"); + command.Options.ShouldContain(o => o.Name == "database"); + command.Options.ShouldContain(o => o.Name == "user"); + command.Options.ShouldContain(o => o.Name == "password"); + command.Options.ShouldContain(o => o.Name == "port"); + } + + [Fact] + public void CreateRemoveCommand_ReturnsCommandWithNameArgument() + { + // Act + var command = ConnectionCommands.CreateRemoveCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("remove"); + command.Arguments.ShouldContain(a => a.Name == "name"); + } + + [Fact] + public async Task ListCommand_WithNoConfigFolder_ReturnsError() + { + // Arrange + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(null)); + + var command = ConnectionCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Act + var exitCode = await rootCommand.InvokeAsync(["list"]); + + // Assert - command completes without throwing + command.ShouldNotBeNull(); + } + + [Fact] + public void ListCommand_HasCorrectDescription() + { + // Arrange & Act + var command = ConnectionCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.Description.ShouldBe("List all connection names and providers"); + } + + [Fact] + public async Task ShowCommand_WithValidConnection_ShowsDetailsWithMaskedPassword() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var config = new ConfigModel + { + ConnectionStrings = new ConnectionStringsSection + { + Entries = + [ + new ConnectionStringEntry + { + Name = "TestConnection", + Provider = ConnectionProvider.SqlServer, + Server = "localhost", + Database = "TestDb", + UserId = "sa", + Password = "SuperSecretPassword123!" + } + ] + } + }; + + _configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = ConnectionCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console output + var originalOut = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["show", "TestConnection"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("TestConnection"); + output.ShouldContain("SqlServer"); + output.ShouldContain("localhost"); + output.ShouldContain("TestDb"); + output.ShouldContain("********"); // Password should be masked + output.ShouldNotContain("SuperSecretPassword123!"); // Actual password should not appear + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task ShowCommand_WithNonExistentConnection_ReturnsError() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var config = new ConfigModel(); + + _configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = ConnectionCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console error output + var originalErr = Console.Error; + using var writer = new StringWriter(); + Console.SetError(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["show", "NonExistent"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("not found"); + } + finally + { + Console.SetError(originalErr); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task AddCommand_WithNewConnection_AddsConnection() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var config = new ConfigModel(); + + _configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = ConnectionCommands.CreateAddCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console output + var originalOut = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["add", "NewConnection", "--server", "localhost", "--database", "TestDb"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("added successfully"); + await _configFileService.Received(1).SaveAppSettingsAsync(appSettingsPath, Arg.Any(), Arg.Any()); + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task AddCommand_WithExistingConnection_ReturnsError() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var config = new ConfigModel + { + ConnectionStrings = new ConnectionStringsSection + { + Entries = + [ + new ConnectionStringEntry { Name = "ExistingConnection" } + ] + } + }; + + _configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = ConnectionCommands.CreateAddCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console error output + var originalErr = Console.Error; + using var writer = new StringWriter(); + Console.SetError(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["add", "ExistingConnection", "--server", "localhost"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("already exists"); + } + finally + { + Console.SetError(originalErr); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void RemoveCommand_HasCorrectDescription() + { + // Arrange & Act + var command = ConnectionCommands.CreateRemoveCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.Description.ShouldBe("Remove a connection"); + } + + [Fact] + public async Task RemoveCommand_WithNonExistentConnection_ReturnsError() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var appSettingsPath = Path.Combine(tempDir, "appsettings.json"); + File.WriteAllText(appSettingsPath, "{}"); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var config = new ConfigModel(); + + _configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = ConnectionCommands.CreateRemoveCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console error output + var originalErr = Console.Error; + using var writer = new StringWriter(); + Console.SetError(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["remove", "NonExistent"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("not found"); + } + finally + { + Console.SetError(originalErr); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } +} diff --git a/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/PipelineCommandsTests.cs b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/PipelineCommandsTests.cs new file mode 100644 index 0000000..ae0eafb --- /dev/null +++ b/NEW/tests/Utils/JdeScoping.ConfigManager.Cli.Tests/Commands/PipelineCommandsTests.cs @@ -0,0 +1,193 @@ +using System.CommandLine; +using JdeScoping.ConfigManager.Cli.Commands; +using JdeScoping.ConfigManager.Core.Services; +using JdeScoping.DataSync.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JdeScoping.ConfigManager.Cli.Tests.Commands; + +public class PipelineCommandsTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IConfigFileService _configFileService; + private readonly IAutoDiscoveryService _autoDiscoveryService; + private readonly Option _configPathOption; + private readonly Option _verboseOption; + private readonly Option _quietOption; + + public PipelineCommandsTests() + { + _configFileService = Substitute.For(); + _autoDiscoveryService = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(_configFileService); + services.AddSingleton(_autoDiscoveryService); + _serviceProvider = services.BuildServiceProvider(); + + _configPathOption = new Option(["--config-path", "-c"]); + _verboseOption = new Option(["--verbose", "-v"]); + _quietOption = new Option(["--quiet", "-q"]); + } + + [Fact] + public void CreateListCommand_ReturnsCommand() + { + // Act + var command = PipelineCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("list"); + } + + [Fact] + public void CreateShowCommand_ReturnsCommandWithNameArgument() + { + // Act + var command = PipelineCommands.CreateShowCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("show"); + command.Arguments.ShouldContain(a => a.Name == "name"); + } + + [Fact] + public void CreateCreateCommand_ReturnsCommandWithOptions() + { + // Act + var command = PipelineCommands.CreateCreateCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("create"); + command.Arguments.ShouldContain(a => a.Name == "name"); + command.Options.ShouldContain(o => o.Name == "enabled"); + command.Options.ShouldContain(o => o.Name == "manual-only"); + } + + [Fact] + public void CreateDeleteCommand_ReturnsCommandWithForceOption() + { + // Act + var command = PipelineCommands.CreateDeleteCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("delete"); + command.Arguments.ShouldContain(a => a.Name == "name"); + command.Options.ShouldContain(o => o.Name == "force"); + } + + [Fact] + public void CreateEnableCommand_ReturnsCommand() + { + // Act + var command = PipelineCommands.CreateEnableCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("enable"); + command.Arguments.ShouldContain(a => a.Name == "name"); + } + + [Fact] + public void CreateDisableCommand_ReturnsCommand() + { + // Act + var command = PipelineCommands.CreateDisableCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + + // Assert + command.ShouldNotBeNull(); + command.Name.ShouldBe("disable"); + command.Arguments.ShouldContain(a => a.Name == "name"); + } + + [Fact] + public async Task ListCommand_WithNoConfigFolder_ReturnsError() + { + // Arrange + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(null)); + + var command = PipelineCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Act + var exitCode = await rootCommand.InvokeAsync(["list"]); + + // Assert - exit code is set via Environment.ExitCode + // The command should complete without throwing + command.ShouldNotBeNull(); + } + + [Fact] + public async Task ListCommand_WithValidPipelines_ReturnsSuccess() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var pipelinesDir = Path.Combine(tempDir, "Pipelines"); + Directory.CreateDirectory(pipelinesDir); + + try + { + _autoDiscoveryService.FindConfigFolderAsync(Arg.Any()) + .Returns(Task.FromResult(tempDir)); + + var pipelines = new Dictionary + { + ["WorkOrder_Curr"] = new EtlPipelineConfig + { + Name = "WorkOrder_Curr", + IsEnabled = true, + MassSyncIntervalMinutes = 10080, + DailySyncIntervalMinutes = 1440 + } + }; + + _configFileService.LoadAllPipelinesAsync(pipelinesDir, Arg.Any()) + .Returns(Task.FromResult(pipelines)); + + var command = PipelineCommands.CreateListCommand( + _serviceProvider, _configPathOption, _verboseOption, _quietOption); + var rootCommand = new RootCommand { command }; + rootCommand.AddGlobalOption(_configPathOption); + rootCommand.AddGlobalOption(_verboseOption); + rootCommand.AddGlobalOption(_quietOption); + + // Capture console output + var originalOut = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + // Act + await rootCommand.InvokeAsync(["list"]); + + // Assert + var output = writer.ToString(); + output.ShouldContain("WorkOrder_Curr"); + } + finally + { + Console.SetOut(originalOut); + } + } + finally + { + Directory.Delete(tempDir, true); + } + } +}