feat(configmanager): add CLI feature parity with UI

Expand ConfigManager.Cli with pipeline, config, backup, and connection commands
to match the feature set of ConfigManager.Ui, plus README documentation.
This commit is contained in:
Joseph Doherty
2026-01-28 10:40:25 -05:00
parent 1fc7792cd1
commit bad0102af1
10 changed files with 3158 additions and 0 deletions
@@ -0,0 +1,347 @@
using System.CommandLine;
using JdeScoping.ConfigManager.Core.Services;
using Microsoft.Extensions.DependencyInjection;
namespace JdeScoping.ConfigManager.Cli.Commands;
/// <summary>
/// Backup management command implementations.
/// </summary>
public static class BackupCommands
{
/// <summary>
/// Creates the backup create command.
/// </summary>
public static Command CreateCreateCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> 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;
}
/// <summary>
/// Creates the backup list command.
/// </summary>
public static Command CreateListCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> 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;
}
/// <summary>
/// Creates the backup restore command.
/// </summary>
public static Command CreateRestoreCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("restore", "Restore a specific backup");
var timestampArgument = new Argument<string>("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;
}
/// <summary>
/// Creates the backup cleanup command.
/// </summary>
public static Command CreateCleanupCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("cleanup", "Remove old backups");
var keepOption = new Option<int>(
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<int> 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<IBackupService>();
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<int> 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<IBackupService>();
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<int> 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<IBackupService>();
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<int> 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<IBackupService>();
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<string?> GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath)
{
if (!string.IsNullOrEmpty(configPath))
{
if (Directory.Exists(configPath))
return configPath;
return null;
}
var autoDiscoveryService = serviceProvider.GetRequiredService<IAutoDiscoveryService>();
return await autoDiscoveryService.FindConfigFolderAsync();
}
}
@@ -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;
/// <summary>
/// Configuration viewing command implementations.
/// </summary>
public static class ConfigCommands
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Creates the config show command with section subcommands.
/// </summary>
public static Command CreateShowCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("show", "View configuration settings");
var jsonOption = new Option<bool>(
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<T>(
string name,
string description,
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption,
Option<bool> jsonOption,
Func<Core.Models.ConfigModel, T> selector,
Action<T, bool> 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<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption,
Option<bool> 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<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption,
Option<bool> 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<int> ShowSectionAsync<T>(
IServiceProvider serviceProvider,
string? configPath,
bool verbose,
bool quiet,
bool json,
string sectionName,
Func<Core.Models.ConfigModel, T> selector,
Action<T, bool> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<string?> GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath)
{
if (!string.IsNullOrEmpty(configPath))
{
if (Directory.Exists(configPath))
return configPath;
return null;
}
var autoDiscoveryService = serviceProvider.GetRequiredService<IAutoDiscoveryService>();
return await autoDiscoveryService.FindConfigFolderAsync();
}
}
@@ -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;
/// <summary>
/// Connection string management command implementations.
/// </summary>
public static class ConnectionCommands
{
/// <summary>
/// Creates the connection list command.
/// </summary>
public static Command CreateListCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> 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;
}
/// <summary>
/// Creates the connection show command.
/// </summary>
public static Command CreateShowCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("show", "Show connection details (password masked)");
var nameArgument = new Argument<string>("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;
}
/// <summary>
/// Creates the connection add command.
/// </summary>
public static Command CreateAddCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("add", "Add new connection");
var nameArgument = new Argument<string>("name", "The connection name");
command.AddArgument(nameArgument);
var providerOption = new Option<ConnectionProvider>(
aliases: ["--provider", "-p"],
getDefaultValue: () => ConnectionProvider.SqlServer,
description: "Database provider (SqlServer, Oracle, Generic)");
command.AddOption(providerOption);
var serverOption = new Option<string?>(
aliases: ["--server", "-s"],
description: "Server name or host");
command.AddOption(serverOption);
var databaseOption = new Option<string?>(
aliases: ["--database", "-d"],
description: "Database name");
command.AddOption(databaseOption);
var userOption = new Option<string?>(
aliases: ["--user", "-u"],
description: "User ID");
command.AddOption(userOption);
var passwordOption = new Option<string?>(
aliases: ["--password"],
description: "Password (will be stored in config)");
command.AddOption(passwordOption);
var portOption = new Option<int?>(
aliases: ["--port"],
description: "Port number");
command.AddOption(portOption);
var serviceNameOption = new Option<string?>(
aliases: ["--service-name"],
description: "Oracle service name");
command.AddOption(serviceNameOption);
var rawOption = new Option<string?>(
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;
}
/// <summary>
/// Creates the connection remove command.
/// </summary>
public static Command CreateRemoveCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("remove", "Remove a connection");
var nameArgument = new Argument<string>("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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<string?> GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath)
{
if (!string.IsNullOrEmpty(configPath))
{
if (Directory.Exists(configPath))
return configPath;
return null;
}
var autoDiscoveryService = serviceProvider.GetRequiredService<IAutoDiscoveryService>();
return await autoDiscoveryService.FindConfigFolderAsync();
}
}
@@ -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;
/// <summary>
/// Pipeline management command implementations.
/// </summary>
public static class PipelineCommands
{
/// <summary>
/// Creates the pipeline list command.
/// </summary>
public static Command CreateListCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> 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;
}
/// <summary>
/// Creates the pipeline show command.
/// </summary>
public static Command CreateShowCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("show", "Show detailed pipeline configuration");
var nameArgument = new Argument<string>("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;
}
/// <summary>
/// Creates the pipeline create command.
/// </summary>
public static Command CreateCreateCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("create", "Create new pipeline with defaults");
var nameArgument = new Argument<string>("name", "The pipeline name");
command.AddArgument(nameArgument);
var enabledOption = new Option<bool>(
aliases: ["--enabled", "-e"],
getDefaultValue: () => true,
description: "Whether the pipeline is enabled");
command.AddOption(enabledOption);
var manualOnlyOption = new Option<bool>(
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;
}
/// <summary>
/// Creates the pipeline delete command.
/// </summary>
public static Command CreateDeleteCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("delete", "Delete a pipeline");
var nameArgument = new Argument<string>("name", "The pipeline name to delete");
command.AddArgument(nameArgument);
var forceOption = new Option<bool>(
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;
}
/// <summary>
/// Creates the pipeline enable command.
/// </summary>
public static Command CreateEnableCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("enable", "Enable a pipeline");
var nameArgument = new Argument<string>("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;
}
/// <summary>
/// Creates the pipeline disable command.
/// </summary>
public static Command CreateDisableCommand(
IServiceProvider serviceProvider,
Option<string?> configPathOption,
Option<bool> verboseOption,
Option<bool> quietOption)
{
var command = new Command("disable", "Disable a pipeline");
var nameArgument = new Argument<string>("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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<int> 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<IConfigFileService>();
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<string?> GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath)
{
if (!string.IsNullOrEmpty(configPath))
{
if (Directory.Exists(configPath))
return configPath;
return null;
}
var autoDiscoveryService = serviceProvider.GetRequiredService<IAutoDiscoveryService>();
return await autoDiscoveryService.FindConfigFolderAsync();
}
}
@@ -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);
}
@@ -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 <key>
# Set or update a secret
jdescoping-config secret set <key> <value>
# Remove a secret
jdescoping-config secret remove <key>
```
### 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 <name>
# Create a new pipeline with defaults
jdescoping-config pipeline create <name>
jdescoping-config pipeline create <name> --enabled false
jdescoping-config pipeline create <name> --manual-only
# Delete a pipeline
jdescoping-config pipeline delete <name>
jdescoping-config pipeline delete <name> --force # Skip confirmation
# Enable/disable a pipeline
jdescoping-config pipeline enable <name>
jdescoping-config pipeline disable <name>
```
### 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 <timestamp>
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 <name>
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 <name>
```
## 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
```
@@ -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<string?> _configPathOption;
private readonly Option<bool> _verboseOption;
private readonly Option<bool> _quietOption;
public BackupCommandsTests()
{
_backupService = Substitute.For<IBackupService>();
_autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
var services = new ServiceCollection();
services.AddSingleton(_backupService);
services.AddSingleton(_autoDiscoveryService);
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
_verboseOption = new Option<bool>(["--verbose", "-v"]);
_quietOption = new Option<bool>(["--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<CancellationToken>())
.Returns(Task.FromResult<string?>(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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
_backupService.CreateBackupAsync(appSettingsPath, Arg.Any<CancellationToken>())
.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<CancellationToken>());
}
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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
_backupService.GetBackupsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BackupInfo>>(new List<BackupInfo>()));
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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
_backupService.GetBackupsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BackupInfo>>(new List<BackupInfo>()));
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<CancellationToken>());
}
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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
_backupService.GetBackupsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BackupInfo>>(new List<BackupInfo>()));
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);
}
}
}
@@ -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<string?> _configPathOption;
private readonly Option<bool> _verboseOption;
private readonly Option<bool> _quietOption;
public ConfigCommandsTests()
{
_configFileService = Substitute.For<IConfigFileService>();
_autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
var services = new ServiceCollection();
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
_verboseOption = new Option<bool>(["--verbose", "-v"]);
_quietOption = new Option<bool>(["--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<CancellationToken>())
.Returns(Task.FromResult<string?>(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<CancellationToken>())
.Returns(Task.FromResult<string?>(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<CancellationToken>())
.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");
}
}
@@ -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<string?> _configPathOption;
private readonly Option<bool> _verboseOption;
private readonly Option<bool> _quietOption;
public ConnectionCommandsTests()
{
_configFileService = Substitute.For<IConfigFileService>();
_autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
var services = new ServiceCollection();
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
_verboseOption = new Option<bool>(["--verbose", "-v"]);
_quietOption = new Option<bool>(["--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<CancellationToken>())
.Returns(Task.FromResult<string?>(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<CancellationToken>())
.Returns(Task.FromResult<string?>(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<CancellationToken>())
.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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
var config = new ConfigModel();
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
var config = new ConfigModel();
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.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<ConfigModel>(), Arg.Any<CancellationToken>());
}
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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
var config = new ConfigModel
{
ConnectionStrings = new ConnectionStringsSection
{
Entries =
[
new ConnectionStringEntry { Name = "ExistingConnection" }
]
}
};
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
var config = new ConfigModel();
_configFileService.LoadAppSettingsAsync(appSettingsPath, Arg.Any<CancellationToken>())
.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);
}
}
}
@@ -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<string?> _configPathOption;
private readonly Option<bool> _verboseOption;
private readonly Option<bool> _quietOption;
public PipelineCommandsTests()
{
_configFileService = Substitute.For<IConfigFileService>();
_autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
var services = new ServiceCollection();
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
_verboseOption = new Option<bool>(["--verbose", "-v"]);
_quietOption = new Option<bool>(["--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<CancellationToken>())
.Returns(Task.FromResult<string?>(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<CancellationToken>())
.Returns(Task.FromResult<string?>(tempDir));
var pipelines = new Dictionary<string, EtlPipelineConfig>
{
["WorkOrder_Curr"] = new EtlPipelineConfig
{
Name = "WorkOrder_Curr",
IsEnabled = true,
MassSyncIntervalMinutes = 10080,
DailySyncIntervalMinutes = 1440
}
};
_configFileService.LoadAllPipelinesAsync(pipelinesDir, Arg.Any<CancellationToken>())
.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);
}
}
}