refactor(configmanager): convert CLI to structured logging with Serilog

Replace Console.WriteLine calls with ILogger usage across all CLI commands.
Serilog is configured via DI with clean message-only output suitable for
CLI tooling. Log levels map to --quiet (Warning), default (Information),
and --verbose (Debug) flags.

- Add Serilog packages and configure in Program.cs
- Convert all 7 command files to use ILoggerFactory from DI
- Add BeginScope with context properties (Command, ConfigPath, etc.)
- Create logging_style.md documenting patterns and best practices
- Update tests with TestLoggingHelper for Serilog test configuration
This commit is contained in:
Joseph Doherty
2026-01-28 15:53:08 -05:00
parent 61694ca50b
commit 6f3e12b3b4
18 changed files with 1684 additions and 1220 deletions
@@ -1,6 +1,7 @@
using System.CommandLine;
using JdeScoping.ConfigManager.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -104,38 +105,45 @@ public static class BackupCommands
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("BackupCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "backup create",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var backupPath = await backupService.CreateBackupAsync(appSettingsPath);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine("Backup created successfully");
Console.WriteLine($"Path: {backupPath}");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
var backupService = serviceProvider.GetRequiredService<IBackupService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
try
{
var backupPath = await backupService.CreateBackupAsync(appSettingsPath);
logger.LogInformation("Backup created successfully");
logger.LogInformation("Path: {BackupPath}", backupPath);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create backup");
return 1;
}
}
}
@@ -145,53 +153,56 @@ public static class BackupCommands
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("BackupCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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
["Command"] = "backup list",
["ConfigPath"] = configPath ?? "(default)"
}))
{
var backups = await backupService.GetBackupsAsync(appSettingsPath);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine($"=== Backups ({backups.Count}) ===");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
if (backups.Count == 0)
var backupService = serviceProvider.GetRequiredService<IBackupService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
try
{
if (!quiet)
Console.WriteLine("No backups found");
var backups = await backupService.GetBackupsAsync(appSettingsPath);
logger.LogInformation("=== Backups ({Count}) ===", backups.Count);
if (backups.Count == 0)
{
logger.LogInformation("No backups found");
return 0;
}
logger.LogInformation("{Timestamp,-20} {Size,-12} {Path}", "Timestamp", "Size", "Path");
logger.LogInformation("{Separator}", 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);
logger.LogInformation("{Timestamp,-20} {Size,-12} {Path}", timestamp, size, path);
}
return 0;
}
if (!quiet)
catch (Exception ex)
{
Console.WriteLine($"{"Timestamp",-20} {"Size",-12} {"Path"}");
Console.WriteLine(new string('-', 60));
logger.LogError(ex, "Failed to list backups");
return 1;
}
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;
}
}
@@ -202,62 +213,66 @@ public static class BackupCommands
bool quiet,
string timestamp)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("BackupCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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
["Command"] = "backup restore",
["ConfigPath"] = configPath ?? "(default)",
["BackupTimestamp"] = timestamp
}))
{
var backups = await backupService.GetBackupsAsync(appSettingsPath);
var backup = backups.FirstOrDefault(b => b.Path.Contains(timestamp));
if (backup == null)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == 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}");
}
}
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
// Create a backup of current config before restoring
if (File.Exists(appSettingsPath))
var backupService = serviceProvider.GetRequiredService<IBackupService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
try
{
var currentBackup = await backupService.CreateBackupAsync(appSettingsPath);
if (!quiet)
var backups = await backupService.GetBackupsAsync(appSettingsPath);
var backup = backups.FirstOrDefault(b => b.Path.Contains(timestamp));
if (backup == null)
{
Console.WriteLine($"Current config backed up to: {Path.GetFileName(currentBackup)}");
logger.LogError("Backup with timestamp '{Timestamp}' not found", timestamp);
if (backups.Count > 0)
{
logger.LogError("Available backups:");
foreach (var b in backups.Take(5))
{
logger.LogError(" {Timestamp}", b.Timestamp.ToString("yyyy-MM-dd_HHmmss"));
}
}
return 1;
}
// Create a backup of current config before restoring
if (File.Exists(appSettingsPath))
{
var currentBackup = await backupService.CreateBackupAsync(appSettingsPath);
logger.LogInformation("Current config backed up to: {BackupName}", Path.GetFileName(currentBackup));
}
await backupService.RestoreBackupAsync(backup.Path, appSettingsPath);
logger.LogInformation("Backup restored successfully");
logger.LogInformation("Restored from: {BackupName}", Path.GetFileName(backup.Path));
return 0;
}
await backupService.RestoreBackupAsync(backup.Path, appSettingsPath);
if (!quiet)
catch (Exception ex)
{
Console.WriteLine("Backup restored successfully");
Console.WriteLine($"Restored from: {Path.GetFileName(backup.Path)}");
logger.LogError(ex, "Failed to restore backup");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -268,52 +283,60 @@ public static class BackupCommands
bool quiet,
int keep)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("BackupCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
if (keep < 1)
["Command"] = "backup cleanup",
["ConfigPath"] = configPath ?? "(default)",
["KeepCount"] = keep
}))
{
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)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
if (keep < 1)
{
logger.LogError("--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 (deleted > 0)
{
Console.WriteLine($"Deleted {deleted} old backup(s)");
Console.WriteLine($"Remaining: {backupsAfter.Count} backup(s)");
logger.LogInformation("Deleted {Count} old backup(s)", deleted);
logger.LogInformation("Remaining: {Count} backup(s)", backupsAfter.Count);
}
else
{
Console.WriteLine("No backups to delete");
Console.WriteLine($"Current count: {countBefore} (keeping {keep})");
logger.LogInformation("No backups to delete");
logger.LogInformation("Current count: {Count} (keeping {Keep})", countBefore, keep);
}
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to cleanup backups");
return 1;
}
}
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using JdeScoping.ConfigManager.Core.Models;
using JdeScoping.ConfigManager.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -78,7 +79,7 @@ public static class ConfigCommands
Option<bool> quietOption,
Option<bool> jsonOption,
Func<Core.Models.ConfigModel, T> selector,
Action<T, bool> formatter)
Action<T, bool, ILogger> formatter)
{
var command = new Command(name, description);
@@ -135,48 +136,55 @@ public static class ConfigCommands
bool json,
string sectionName,
Func<Core.Models.ConfigModel, T> selector,
Action<T, bool> formatter)
Action<T, bool, ILogger> formatter)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConfigCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = $"config show {sectionName}",
["ConfigPath"] = configPath ?? "(default)"
}))
{
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)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine(JsonSerializer.Serialize(section, JsonOptions));
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
else
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
if (!quiet)
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var section = selector(config);
if (json)
{
Console.WriteLine($"=== {sectionName} ===");
logger.LogInformation("{Data}", JsonSerializer.Serialize(section, JsonOptions));
}
else
{
logger.LogInformation("=== {SectionName} ===", sectionName);
formatter(section, verbose, logger);
}
formatter(section, verbose);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to show section");
return 1;
}
}
}
@@ -187,57 +195,64 @@ public static class ConfigCommands
bool quiet,
bool json)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConfigCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "config show connections",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
if (json)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
// 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}");
}
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
logger.LogError("appsettings.json not found at {Path}", 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()
});
logger.LogInformation("{Data}", JsonSerializer.Serialize(safeConnections, JsonOptions));
}
else
{
logger.LogInformation("=== Connections ({Count}) ===", config.ConnectionStrings.Entries.Count);
logger.LogInformation("{Name,-25} {Provider,-15}", "Name", "Provider");
logger.LogInformation("{Separator}", new string('-', 40));
foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name))
{
logger.LogInformation("{Name,-25} {Provider,-15}", entry.Name, entry.Provider);
}
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to show connections");
return 1;
}
}
}
@@ -248,177 +263,187 @@ public static class ConfigCommands
bool quiet,
bool json)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConfigCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "config show all",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
if (json)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
// Sanitize passwords before outputting
var sanitizedConfig = new
logger.LogError("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))
{
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
if (json)
{
config.DataSync,
config.DataAccess,
config.Auth,
config.Ldap,
config.Search,
config.ExcelExport,
config.SecureStore,
config.Pipelines,
Connections = config.ConnectionStrings.Entries.Select(e => new
// Sanitize passwords before outputting
var sanitizedConfig = 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}");
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()
})
};
logger.LogInformation("{Data}", JsonSerializer.Serialize(sanitizedConfig, JsonOptions));
}
else
{
logger.LogInformation("=== DataSync ===");
FormatDataSync(config.DataSync, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== DataAccess ===");
FormatDataAccess(config.DataAccess, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== Auth ===");
FormatAuth(config.Auth, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== LDAP ===");
FormatLdap(config.Ldap, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== Search ===");
FormatSearch(config.Search, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== ExcelExport ===");
FormatExcelExport(config.ExcelExport, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== SecureStore ===");
FormatSecureStore(config.SecureStore, verbose, logger);
logger.LogInformation("");
logger.LogInformation("=== Connections ({Count}) ===", config.ConnectionStrings.Entries.Count);
logger.LogInformation("{Name,-25} {Provider,-15}", "Name", "Provider");
logger.LogInformation("{Separator}", new string('-', 40));
foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name))
{
logger.LogInformation("{Name,-25} {Provider,-15}", entry.Name, entry.Provider);
}
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to show all sections");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
private static void FormatDataSync(Core.Models.DataSyncSection section, bool verbose)
private static void FormatDataSync(Core.Models.DataSyncSection section, bool verbose, ILogger logger)
{
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}");
logger.LogInformation("Enabled: {Value}", section.Enabled);
logger.LogInformation("CheckInterval: {Value}", section.CheckInterval);
logger.LogInformation("MaxDegreeOfParallelism: {Value}", section.MaxDegreeOfParallelism);
logger.LogInformation("BatchSize: {Value}", section.BatchSize);
logger.LogInformation("BulkCopyBatchSize: {Value}", section.BulkCopyBatchSize);
if (verbose)
{
Console.WriteLine($"LookbackMultiplier: {section.LookbackMultiplier}");
Console.WriteLine($"PurgeRetentionDays: {section.PurgeRetentionDays}");
Console.WriteLine($"SyncTimeoutSeconds: {section.SyncTimeoutSeconds}");
logger.LogDebug("LookbackMultiplier: {Value}", section.LookbackMultiplier);
logger.LogDebug("PurgeRetentionDays: {Value}", section.PurgeRetentionDays);
logger.LogDebug("SyncTimeoutSeconds: {Value}", section.SyncTimeoutSeconds);
}
}
private static void FormatDataAccess(Core.Models.DataAccessSection section, bool verbose)
private static void FormatDataAccess(Core.Models.DataAccessSection section, bool verbose, ILogger logger)
{
Console.WriteLine($"DefaultTimeoutSeconds: {section.DefaultTimeoutSeconds}");
Console.WriteLine($"LotUsageTimeoutSeconds: {section.LotUsageTimeoutSeconds}");
Console.WriteLine($"MisDataTimeoutSeconds: {section.MisDataTimeoutSeconds}");
Console.WriteLine($"EnableDetailedLogging: {section.EnableDetailedLogging}");
logger.LogInformation("DefaultTimeoutSeconds: {Value}", section.DefaultTimeoutSeconds);
logger.LogInformation("LotUsageTimeoutSeconds: {Value}", section.LotUsageTimeoutSeconds);
logger.LogInformation("MisDataTimeoutSeconds: {Value}", section.MisDataTimeoutSeconds);
logger.LogInformation("EnableDetailedLogging: {Value}", section.EnableDetailedLogging);
if (verbose)
{
Console.WriteLine($"ProductionSchema: {section.ProductionSchema}");
Console.WriteLine($"ArchiveSchema: {section.ArchiveSchema}");
Console.WriteLine($"StageSchema: {section.StageSchema}");
logger.LogDebug("ProductionSchema: {Value}", section.ProductionSchema);
logger.LogDebug("ArchiveSchema: {Value}", section.ArchiveSchema);
logger.LogDebug("StageSchema: {Value}", section.StageSchema);
}
}
private static void FormatAuth(Core.Models.AuthSection section, bool verbose)
private static void FormatAuth(Core.Models.AuthSection section, bool verbose, ILogger logger)
{
Console.WriteLine($"CookieName: {section.CookieName}");
Console.WriteLine($"CookieExpirationMinutes: {section.CookieExpirationMinutes}");
logger.LogInformation("CookieName: {Value}", section.CookieName);
logger.LogInformation("CookieExpirationMinutes: {Value}", section.CookieExpirationMinutes);
}
private static void FormatLdap(Core.Models.LdapSection section, bool verbose)
private static void FormatLdap(Core.Models.LdapSection section, bool verbose, ILogger logger)
{
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)}");
logger.LogInformation("UseFakeAuth: {Value}", section.UseFakeAuth);
logger.LogInformation("ConnectionTimeoutSeconds: {Value}", section.ConnectionTimeoutSeconds);
logger.LogInformation("ServerUrls: {Value}", section.ServerUrls.Length > 0 ? string.Join(", ", section.ServerUrls) : "(none)");
logger.LogInformation("GroupDn: {Value}", 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)")}");
logger.LogDebug("SearchBase: {Value}", string.IsNullOrEmpty(section.SearchBase) ? "(not set)" : section.SearchBase);
logger.LogDebug("AdminBypassUsers: {Value}", section.AdminBypassUsers.Length > 0 ? string.Join(", ", section.AdminBypassUsers) : "(none)");
}
}
private static void FormatSearch(Core.Models.SearchSection section, bool verbose)
private static void FormatSearch(Core.Models.SearchSection section, bool verbose, ILogger logger)
{
Console.WriteLine($"MaxResultRows: {section.MaxResultRows}");
Console.WriteLine($"TimeoutSeconds: {section.TimeoutSeconds}");
Console.WriteLine($"MaxConcurrentSearches: {section.MaxConcurrentSearches}");
logger.LogInformation("MaxResultRows: {Value}", section.MaxResultRows);
logger.LogInformation("TimeoutSeconds: {Value}", section.TimeoutSeconds);
logger.LogInformation("MaxConcurrentSearches: {Value}", section.MaxConcurrentSearches);
}
private static void FormatExcelExport(Core.Models.ExcelExportSection section, bool verbose)
private static void FormatExcelExport(Core.Models.ExcelExportSection section, bool verbose, ILogger logger)
{
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}");
logger.LogInformation("MaxRowsPerSheet: {Value}", section.MaxRowsPerSheet);
logger.LogInformation("DefaultDateFormat: {Value}", section.DefaultDateFormat);
logger.LogInformation("TimezoneId: {Value}", section.TimezoneId);
logger.LogInformation("TimezoneAbbreviation: {Value}", section.TimezoneAbbreviation);
logger.LogInformation("DebugWriteToFile: {Value}", section.DebugWriteToFile);
if (verbose && section.DebugWriteToFile)
{
Console.WriteLine($"DebugOutputDirectory: {section.DebugOutputDirectory}");
logger.LogDebug("DebugOutputDirectory: {Value}", section.DebugOutputDirectory);
}
// Note: passwords are not shown
Console.WriteLine("CriteriaSheetPassword: ********");
Console.WriteLine("DataSheetPassword: ********");
logger.LogInformation("CriteriaSheetPassword: {Value}", "********");
logger.LogInformation("DataSheetPassword: {Value}", "********");
}
private static void FormatSecureStore(Core.Models.SecureStoreSection section, bool verbose)
private static void FormatSecureStore(Core.Models.SecureStoreSection section, bool verbose, ILogger logger)
{
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)")}");
logger.LogInformation("StorePath: {Value}", section.StorePath);
logger.LogInformation("KeyFilePath: {Value}", section.KeyFilePath);
logger.LogInformation("AutoCreateStore: {Value}", section.AutoCreateStore);
logger.LogInformation("RequiredKeys: {Value}", section.RequiredKeys.Count > 0 ? string.Join(", ", section.RequiredKeys) : "(none)");
}
private static async Task<string?> GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath)
@@ -980,54 +1005,58 @@ public static class ConfigCommands
string sectionName,
Func<ConfigModel, bool> modifier)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConfigCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var backupService = serviceProvider.GetRequiredService<IBackupService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
["Command"] = $"config set {sectionName.ToLower()}",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var modified = modifier(config);
if (!modified)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.Error.WriteLine("Error: No changes specified. Use --help to see available options.");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
// Create backup before saving
var backupPath = await backupService.CreateBackupAsync(appSettingsPath);
if (verbose)
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var backupService = serviceProvider.GetRequiredService<IBackupService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
Console.WriteLine($"Backup created: {Path.GetFileName(backupPath)}");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
if (!quiet)
try
{
Console.WriteLine($"{sectionName} configuration updated successfully");
}
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var modified = modifier(config);
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
if (!modified)
{
logger.LogError("No changes specified. Use --help to see available options");
return 1;
}
// Create backup before saving
var backupPath = await backupService.CreateBackupAsync(appSettingsPath);
logger.LogDebug("Backup created: {BackupName}", Path.GetFileName(backupPath));
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
logger.LogInformation("{SectionName} configuration updated successfully", sectionName);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update configuration");
return 1;
}
}
}
}
@@ -2,6 +2,7 @@ using System.CommandLine;
using JdeScoping.ConfigManager.Core.Models;
using JdeScoping.ConfigManager.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -239,52 +240,59 @@ public static class ConnectionCommands
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConnectionCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "connection list",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine($"=== Connections ({config.ConnectionStrings.Entries.Count}) ===");
Console.WriteLine($"{"Name",-25} {"Provider",-15} {"Server/Host",-30}");
Console.WriteLine(new string('-', 70));
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name))
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
var serverOrHost = entry.Provider switch
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
logger.LogInformation("=== Connections ({Count}) ===", config.ConnectionStrings.Entries.Count);
logger.LogInformation("{Name,-25} {Provider,-15} {ServerHost,-30}", "Name", "Provider", "Server/Host");
logger.LogInformation("{Separator}", new string('-', 70));
foreach (var entry in config.ConnectionStrings.Entries.OrderBy(e => e.Name))
{
ConnectionProvider.SqlServer => entry.Server ?? "-",
ConnectionProvider.Oracle => entry.Host ?? "-",
ConnectionProvider.Generic => "(raw)",
_ => "-"
};
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}");
logger.LogInformation("{Name,-25} {Provider,-15} {ServerHost,-30}", entry.Name, entry.Provider, serverOrHost);
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list connections");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -295,84 +303,92 @@ public static class ConnectionCommands
bool quiet,
string name)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConnectionCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "connection show",
["ConfigPath"] = configPath ?? "(default)",
["ConnectionName"] = name
}))
{
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)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.Error.WriteLine($"Error: Connection '{name}' not found");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
if (!quiet)
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
Console.WriteLine($"=== Connection: {entry.Name} ===");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
Console.WriteLine($"Name: {entry.Name}");
Console.WriteLine($"Provider: {entry.Provider}");
switch (entry.Provider)
try
{
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;
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var entry = config.ConnectionStrings.Entries.FirstOrDefault(e =>
e.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
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;
if (entry == null)
{
logger.LogError("Connection '{Name}' not found", name);
return 1;
}
case ConnectionProvider.Generic:
Console.WriteLine($"RawConnectionString: {MaskConnectionString(entry.RawConnectionString)}");
break;
logger.LogInformation("=== Connection: {Name} ===", entry.Name);
logger.LogInformation("Name: {Value}", entry.Name);
logger.LogInformation("Provider: {Value}", entry.Provider);
switch (entry.Provider)
{
case ConnectionProvider.SqlServer:
logger.LogInformation("Server: {Value}", entry.Server ?? "(not set)");
if (entry.SqlServerPort.HasValue)
logger.LogInformation("Port: {Value}", entry.SqlServerPort.Value);
logger.LogInformation("Database: {Value}", entry.Database ?? "(not set)");
logger.LogInformation("User ID: {Value}", entry.UserId ?? "(not set)");
logger.LogInformation("Password: {Value}", MaskPassword(entry.Password));
logger.LogInformation("Encrypt: {Value}", entry.Encrypt);
logger.LogInformation("TrustServerCertificate: {Value}", entry.TrustServerCertificate);
logger.LogInformation("ConnectionTimeout: {Value}", entry.ConnectionTimeout);
if (!string.IsNullOrEmpty(entry.ApplicationName))
logger.LogInformation("ApplicationName: {Value}", entry.ApplicationName);
break;
case ConnectionProvider.Oracle:
logger.LogInformation("Host: {Value}", entry.Host ?? "(not set)");
logger.LogInformation("Port: {Value}", entry.Port);
logger.LogInformation("ServiceName: {Value}", entry.ServiceName ?? "(not set)");
logger.LogInformation("User ID: {Value}", entry.UserId ?? "(not set)");
logger.LogInformation("Password: {Value}", MaskPassword(entry.Password));
break;
case ConnectionProvider.Generic:
logger.LogInformation("RawConnectionString: {Value}", MaskConnectionString(entry.RawConnectionString));
break;
}
if (verbose)
{
logger.LogDebug("");
logger.LogDebug("Generated connection string (masked):");
logger.LogDebug(" {ConnectionString}", MaskConnectionString(entry.GenerateConnectionString()));
}
return 0;
}
if (verbose)
catch (Exception ex)
{
Console.WriteLine();
Console.WriteLine("Generated connection string (masked):");
Console.WriteLine($" {MaskConnectionString(entry.GenerateConnectionString())}");
logger.LogError(ex, "Failed to show connection");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -391,76 +407,84 @@ public static class ConnectionCommands
string? serviceName,
string? raw)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConnectionCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "connection add",
["ConfigPath"] = configPath ?? "(default)",
["ConnectionName"] = name
}))
{
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)))
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.Error.WriteLine($"Error: Connection '{name}' already exists");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
// Validate required fields based on provider
if (provider == ConnectionProvider.Generic && string.IsNullOrEmpty(raw))
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
Console.Error.WriteLine("Error: --raw is required for Generic provider");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
var entry = new ConnectionStringEntry
try
{
Name = name,
Provider = provider,
Server = server,
Database = database,
UserId = user,
Password = password,
Host = server, // For Oracle, use server as host
ServiceName = serviceName,
RawConnectionString = raw
};
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
if (port.HasValue)
{
if (provider == ConnectionProvider.SqlServer)
entry.SqlServerPort = port;
else if (provider == ConnectionProvider.Oracle)
entry.Port = port.Value;
// Check if connection already exists
if (config.ConnectionStrings.Entries.Any(e =>
e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
logger.LogError("Connection '{Name}' already exists", name);
return 1;
}
// Validate required fields based on provider
if (provider == ConnectionProvider.Generic && string.IsNullOrEmpty(raw))
{
logger.LogError("--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);
logger.LogInformation("Connection '{Name}' added successfully", name);
return 0;
}
config.ConnectionStrings.Entries.Add(entry);
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
if (!quiet)
catch (Exception ex)
{
Console.WriteLine($"Connection '{name}' added successfully");
logger.LogError(ex, "Failed to add connection");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -471,48 +495,56 @@ public static class ConnectionCommands
bool quiet,
string name)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConnectionCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "connection remove",
["ConfigPath"] = configPath ?? "(default)",
["ConnectionName"] = name
}))
{
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)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.Error.WriteLine($"Error: Connection '{name}' not found");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
config.ConnectionStrings.Entries.Remove(entry);
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!quiet)
if (!File.Exists(appSettingsPath))
{
Console.WriteLine($"Connection '{name}' removed successfully");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
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)
{
logger.LogError("Connection '{Name}' not found", name);
return 1;
}
config.ConnectionStrings.Entries.Remove(entry);
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
logger.LogInformation("Connection '{Name}' removed successfully", name);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to remove connection");
return 1;
}
}
}
@@ -531,100 +563,108 @@ public static class ConnectionCommands
string? serviceName,
string? raw)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ConnectionCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "connection update",
["ConfigPath"] = configPath ?? "(default)",
["ConnectionName"] = name
}))
{
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)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.Error.WriteLine($"Error: Connection '{name}' not found");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
var modified = false;
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (provider.HasValue)
if (!File.Exists(appSettingsPath))
{
entry.Provider = provider.Value;
modified = true;
}
if (server != null)
{
entry.Server = server;
entry.Host = server; // For Oracle, use server as host
modified = true;
}
if (database != null)
{
entry.Database = database;
modified = true;
}
if (user != null)
{
entry.UserId = user;
modified = true;
}
if (password != null)
{
entry.Password = password;
modified = true;
}
if (port.HasValue)
{
if (entry.Provider == ConnectionProvider.SqlServer)
entry.SqlServerPort = port;
else if (entry.Provider == ConnectionProvider.Oracle)
entry.Port = port.Value;
modified = true;
}
if (serviceName != null)
{
entry.ServiceName = serviceName;
modified = true;
}
if (raw != null)
{
entry.RawConnectionString = raw;
modified = true;
}
if (!modified)
{
Console.Error.WriteLine("Error: No changes specified. Use --help to see available options.");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
if (!quiet)
try
{
Console.WriteLine($"Connection '{name}' updated successfully");
}
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var entry = config.ConnectionStrings.Entries.FirstOrDefault(e =>
e.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
if (entry == null)
{
logger.LogError("Connection '{Name}' not found", name);
return 1;
}
var modified = false;
if (provider.HasValue)
{
entry.Provider = provider.Value;
modified = true;
}
if (server != null)
{
entry.Server = server;
entry.Host = server; // For Oracle, use server as host
modified = true;
}
if (database != null)
{
entry.Database = database;
modified = true;
}
if (user != null)
{
entry.UserId = user;
modified = true;
}
if (password != null)
{
entry.Password = password;
modified = true;
}
if (port.HasValue)
{
if (entry.Provider == ConnectionProvider.SqlServer)
entry.SqlServerPort = port;
else if (entry.Provider == ConnectionProvider.Oracle)
entry.Port = port.Value;
modified = true;
}
if (serviceName != null)
{
entry.ServiceName = serviceName;
modified = true;
}
if (raw != null)
{
entry.RawConnectionString = raw;
modified = true;
}
if (!modified)
{
logger.LogError("No changes specified. Use --help to see available options");
return 1;
}
await configFileService.SaveAppSettingsAsync(appSettingsPath, config);
logger.LogInformation("Connection '{Name}' updated successfully", name);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update connection");
return 1;
}
}
}
@@ -2,6 +2,7 @@ using System.CommandLine;
using JdeScoping.ConfigManager.Core.Services;
using JdeScoping.DataSync.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -167,44 +168,51 @@ public static class PipelineCommands
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("PipelineCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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
["Command"] = "pipeline list",
["ConfigPath"] = configPath ?? "(default)"
}))
{
var pipelines = await configFileService.LoadAllPipelinesAsync(pipelinesDir);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine($"=== Pipelines ({pipelines.Count}) ===");
Console.WriteLine($"{"Name",-20} {"Enabled",-8} {"Mass",-8} {"Daily",-8} {"Hourly",-8}");
Console.WriteLine(new string('-', 52));
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
foreach (var kvp in pipelines.OrderBy(p => p.Key))
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var pipelinesDir = Path.Combine(folderPath, "Pipelines");
try
{
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";
var pipelines = await configFileService.LoadAllPipelinesAsync(pipelinesDir);
Console.WriteLine($"{p.Name,-20} {enabled,-8} {mass,-8} {daily,-8} {hourly,-8}");
logger.LogInformation("=== Pipelines ({Count}) ===", pipelines.Count);
logger.LogInformation("{Name,-20} {Enabled,-8} {Mass,-8} {Daily,-8} {Hourly,-8}", "Name", "Enabled", "Mass", "Daily", "Hourly");
logger.LogInformation("{Separator}", 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";
logger.LogInformation("{Name,-20} {Enabled,-8} {Mass,-8} {Daily,-8} {Hourly,-8}", p.Name, enabled, mass, daily, hourly);
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list pipelines");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -215,86 +223,94 @@ public static class PipelineCommands
bool quiet,
string name)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("PipelineCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = "pipeline show",
["ConfigPath"] = configPath ?? "(default)",
["PipelineName"] = name
}))
{
Console.Error.WriteLine($"Error: Pipeline '{name}' not found");
return 1;
}
try
{
var pipeline = await configFileService.LoadPipelineAsync(pipelinePath);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine($"=== Pipeline: {pipeline.Name} ===");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
Console.WriteLine($"Name: {pipeline.Name}");
Console.WriteLine($"Enabled: {pipeline.IsEnabled}");
Console.WriteLine($"Manual Only: {pipeline.IsManualOnly}");
Console.WriteLine();
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var pipelinePath = Path.Combine(folderPath, "Pipelines", $"pipeline.{name}.json");
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)
if (!File.Exists(pipelinePath))
{
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)}");
logger.LogError("Pipeline '{Name}' not found", name);
return 1;
}
if (pipeline.Destination != null)
try
{
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)}");
}
var pipeline = await configFileService.LoadPipelineAsync(pipelinePath);
if (verbose)
{
if (pipeline.PreScripts.Count > 0)
logger.LogInformation("=== Pipeline: {Name} ===", pipeline.Name);
logger.LogInformation("Name: {Value}", pipeline.Name);
logger.LogInformation("Enabled: {Value}", pipeline.IsEnabled);
logger.LogInformation("Manual Only: {Value}", pipeline.IsManualOnly);
logger.LogInformation("");
logger.LogInformation("Sync Intervals:");
logger.LogInformation(" Mass: {Value}", FormatInterval(pipeline.MassSyncIntervalMinutes));
logger.LogInformation(" Daily: {Value}", FormatInterval(pipeline.DailySyncIntervalMinutes));
logger.LogInformation(" Hourly: {Value}", FormatInterval(pipeline.HourlySyncIntervalMinutes));
if (pipeline.Source != null)
{
Console.WriteLine();
Console.WriteLine($"Pre-Scripts: {pipeline.PreScripts.Count}");
logger.LogInformation("");
logger.LogInformation("Source:");
logger.LogInformation(" Connection: {Value}", pipeline.Source.Connection);
if (!string.IsNullOrEmpty(pipeline.Source.Query))
logger.LogInformation(" Query: {Value}", pipeline.Source.Query.Length > 50 ? pipeline.Source.Query[..50] + "..." : pipeline.Source.Query);
}
if (pipeline.Transforms.Count > 0)
if (pipeline.Destination != null)
{
Console.WriteLine();
Console.WriteLine($"Transforms: {pipeline.Transforms.Count}");
logger.LogInformation("");
logger.LogInformation("Destination:");
logger.LogInformation(" Table: {Value}", pipeline.Destination.Table);
if (pipeline.Destination.MatchColumns.Count > 0)
logger.LogInformation(" Match Columns: {Value}", string.Join(", ", pipeline.Destination.MatchColumns));
}
if (pipeline.PostScripts.Count > 0)
if (verbose)
{
Console.WriteLine();
Console.WriteLine($"Post-Scripts: {pipeline.PostScripts.Count}");
if (pipeline.PreScripts.Count > 0)
{
logger.LogDebug("");
logger.LogDebug("Pre-Scripts: {Count}", pipeline.PreScripts.Count);
}
if (pipeline.Transforms.Count > 0)
{
logger.LogDebug("");
logger.LogDebug("Transforms: {Count}", pipeline.Transforms.Count);
}
if (pipeline.PostScripts.Count > 0)
{
logger.LogDebug("");
logger.LogDebug("Post-Scripts: {Count}", pipeline.PostScripts.Count);
}
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to show pipeline");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -307,54 +323,62 @@ public static class PipelineCommands
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 loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("PipelineCommands");
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var pipelinesDir = Path.Combine(folderPath, "Pipelines");
var pipelinePath = Path.Combine(pipelinesDir, $"pipeline.{name}.json");
if (File.Exists(pipelinePath))
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine($"Error: Pipeline '{name}' already exists");
return 1;
}
try
["Command"] = "pipeline create",
["ConfigPath"] = configPath ?? "(default)",
["PipelineName"] = name
}))
{
// Create Pipelines directory if it doesn't exist
if (!Directory.Exists(pipelinesDir))
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Directory.CreateDirectory(pipelinesDir);
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
var pipeline = new EtlPipelineConfig
{
Name = name,
IsEnabled = enabled,
IsManualOnly = manualOnly,
Source = new SourceElement { Connection = "jde" },
Destination = new DestinationElement { Table = name }
};
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var pipelinesDir = Path.Combine(folderPath, "Pipelines");
var pipelinePath = Path.Combine(pipelinesDir, $"pipeline.{name}.json");
await configFileService.SavePipelineAsync(pipelinePath, pipeline);
if (!quiet)
if (File.Exists(pipelinePath))
{
Console.WriteLine($"Pipeline '{name}' created successfully");
Console.WriteLine($"File: {pipelinePath}");
logger.LogError("Pipeline '{Name}' already exists", name);
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
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);
logger.LogInformation("Pipeline '{Name}' created successfully", name);
logger.LogInformation("File: {Path}", pipelinePath);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create pipeline");
return 1;
}
}
}
@@ -366,50 +390,57 @@ public static class PipelineCommands
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 loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("PipelineCommands");
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var pipelinePath = Path.Combine(folderPath, "Pipelines", $"pipeline.{name}.json");
if (!File.Exists(pipelinePath))
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine($"Error: Pipeline '{name}' not found");
return 1;
}
if (!force)
["Command"] = "pipeline delete",
["ConfigPath"] = configPath ?? "(default)",
["PipelineName"] = name
}))
{
Console.Write($"Delete pipeline '{name}'? [y/N] ");
var response = Console.ReadLine();
if (!string.Equals(response, "y", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(response, "yes", StringComparison.OrdinalIgnoreCase))
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
if (!quiet)
Console.WriteLine("Cancelled");
logger.LogError("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))
{
logger.LogError("Pipeline '{Name}' not found", name);
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))
{
logger.LogInformation("Cancelled");
return 0;
}
}
try
{
await configFileService.DeletePipelineFileAsync(pipelinePath);
logger.LogInformation("Pipeline '{Name}' deleted successfully", name);
return 0;
}
}
try
{
await configFileService.DeletePipelineFileAsync(pipelinePath);
if (!quiet)
catch (Exception ex)
{
Console.WriteLine($"Pipeline '{name}' deleted successfully");
logger.LogError(ex, "Failed to delete pipeline");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
@@ -421,40 +452,49 @@ public static class PipelineCommands
string name,
bool enabled)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("PipelineCommands");
var action = enabled ? "enable" : "disable";
using (logger.BeginScope(new Dictionary<string, object>
{
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))
["Command"] = $"pipeline {action}",
["ConfigPath"] = configPath ?? "(default)",
["PipelineName"] = name
}))
{
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 folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
var action = enabled ? "enabled" : "disabled";
Console.WriteLine($"Pipeline '{name}' {action} successfully");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var pipelinePath = Path.Combine(folderPath, "Pipelines", $"pipeline.{name}.json");
if (!File.Exists(pipelinePath))
{
logger.LogError("Pipeline '{Name}' not found", name);
return 1;
}
try
{
var pipeline = await configFileService.LoadPipelineAsync(pipelinePath);
pipeline.IsEnabled = enabled;
await configFileService.SavePipelineAsync(pipelinePath, pipeline);
var actionPast = enabled ? "enabled" : "disabled";
logger.LogInformation("Pipeline '{Name}' {Action} successfully", name, actionPast);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to {Action} pipeline", action);
return 1;
}
}
}
@@ -2,6 +2,7 @@ using System.CommandLine;
using JdeScoping.ConfigManager.Core.Services;
using JdeScoping.ConfigManager.Core.Services.SecureStore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -138,29 +139,36 @@ public static class SecretCommands
bool verbose,
bool quiet)
{
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath);
if (manager == null)
return 1;
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("SecretCommands");
try
using (logger.BeginScope(new Dictionary<string, object>
{
var keys = manager.GetKeys();
if (!quiet)
{
Console.WriteLine($"=== SecureStore Keys ({keys.Count}) ===");
}
foreach (var key in keys.OrderBy(k => k))
{
Console.WriteLine(key);
}
return 0;
}
finally
["Command"] = "secret list",
["ConfigPath"] = configPath ?? "(default)"
}))
{
manager.CloseStore();
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath, logger);
if (manager == null)
return 1;
try
{
var keys = manager.GetKeys();
logger.LogInformation("=== SecureStore Keys ({Count}) ===", keys.Count);
foreach (var key in keys.OrderBy(k => k))
{
logger.LogInformation("{Key}", key);
}
return 0;
}
finally
{
manager.CloseStore();
}
}
}
@@ -171,24 +179,35 @@ public static class SecretCommands
bool quiet,
string key)
{
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath);
if (manager == null)
return 1;
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("SecretCommands");
try
using (logger.BeginScope(new Dictionary<string, object>
{
var value = manager.GetSecret(key);
Console.WriteLine(value);
return 0;
}
catch (KeyNotFoundException)
["Command"] = "secret get",
["ConfigPath"] = configPath ?? "(default)",
["SecretKey"] = key
}))
{
Console.Error.WriteLine($"Error: Secret '{key}' not found");
return 1;
}
finally
{
manager.CloseStore();
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath, logger);
if (manager == null)
return 1;
try
{
var value = manager.GetSecret(key);
logger.LogInformation("{Value}", value);
return 0;
}
catch (KeyNotFoundException)
{
logger.LogError("Secret '{Key}' not found", key);
return 1;
}
finally
{
manager.CloseStore();
}
}
}
@@ -200,30 +219,38 @@ public static class SecretCommands
string key,
string value)
{
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath);
if (manager == null)
return 1;
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("SecretCommands");
try
using (logger.BeginScope(new Dictionary<string, object>
{
manager.SetSecret(key, value);
manager.Save();
["Command"] = "secret set",
["ConfigPath"] = configPath ?? "(default)",
["SecretKey"] = key
}))
{
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath, logger);
if (manager == null)
return 1;
if (!quiet)
try
{
Console.WriteLine($"Secret '{key}' set successfully");
}
manager.SetSecret(key, value);
manager.Save();
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
finally
{
manager.CloseStore();
logger.LogInformation("Secret '{Key}' set successfully", key);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to set secret");
return 1;
}
finally
{
manager.CloseStore();
}
}
}
@@ -234,35 +261,43 @@ public static class SecretCommands
bool quiet,
string key)
{
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath);
if (manager == null)
return 1;
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("SecretCommands");
try
using (logger.BeginScope(new Dictionary<string, object>
{
manager.RemoveSecret(key);
manager.Save();
["Command"] = "secret remove",
["ConfigPath"] = configPath ?? "(default)",
["SecretKey"] = key
}))
{
var (manager, folderPath) = await OpenStoreAsync(serviceProvider, configPath, logger);
if (manager == null)
return 1;
if (!quiet)
try
{
Console.WriteLine($"Secret '{key}' removed successfully");
}
manager.RemoveSecret(key);
manager.Save();
return 0;
}
catch (KeyNotFoundException)
{
Console.Error.WriteLine($"Error: Secret '{key}' not found");
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
finally
{
manager.CloseStore();
logger.LogInformation("Secret '{Key}' removed successfully", key);
return 0;
}
catch (KeyNotFoundException)
{
logger.LogError("Secret '{Key}' not found", key);
return 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to remove secret");
return 1;
}
finally
{
manager.CloseStore();
}
}
}
@@ -274,56 +309,64 @@ public static class SecretCommands
string? storePath,
string? keyPath)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("SecretCommands");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var effectiveStorePath = storePath ?? Path.Combine(folderPath, "data", "secrets.json");
var effectiveKeyPath = keyPath ?? Path.Combine(folderPath, "data", "secrets.key");
if (File.Exists(effectiveStorePath))
["Command"] = "secret init",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: Store already exists at {effectiveStorePath}");
return 1;
}
var manager = serviceProvider.GetRequiredService<ISecureStoreManager>();
try
{
manager.CreateStore(effectiveStorePath, effectiveKeyPath);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine("SecureStore initialized successfully");
Console.WriteLine($"Store: {effectiveStorePath}");
Console.WriteLine($"Key: {effectiveKeyPath}");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error creating store: {ex.Message}");
return 1;
}
finally
{
manager.CloseStore();
var effectiveStorePath = storePath ?? Path.Combine(folderPath, "data", "secrets.json");
var effectiveKeyPath = keyPath ?? Path.Combine(folderPath, "data", "secrets.key");
if (File.Exists(effectiveStorePath))
{
logger.LogError("Store already exists at {StorePath}", effectiveStorePath);
return 1;
}
var manager = serviceProvider.GetRequiredService<ISecureStoreManager>();
try
{
manager.CreateStore(effectiveStorePath, effectiveKeyPath);
logger.LogInformation("SecureStore initialized successfully");
logger.LogInformation("Store: {StorePath}", effectiveStorePath);
logger.LogInformation("Key: {KeyPath}", effectiveKeyPath);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create store");
return 1;
}
finally
{
manager.CloseStore();
}
}
}
private static async Task<(ISecureStoreManager?, string?)> OpenStoreAsync(
IServiceProvider serviceProvider,
string? configPath)
string? configPath,
ILogger logger)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return (null, null);
}
@@ -333,7 +376,7 @@ public static class SecretCommands
if (!File.Exists(appSettingsPath))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return (null, null);
}
@@ -351,14 +394,14 @@ public static class SecretCommands
if (!File.Exists(storePath))
{
Console.Error.WriteLine($"Error: SecureStore not found at {storePath}");
Console.Error.WriteLine("Use 'secret init' to create a new store.");
logger.LogError("SecureStore not found at {Path}", storePath);
logger.LogError("Use 'secret init' to create a new store");
return (null, null);
}
if (!File.Exists(keyPath))
{
Console.Error.WriteLine($"Error: Key file not found at {keyPath}");
logger.LogError("Key file not found at {Path}", keyPath);
return (null, null);
}
@@ -369,7 +412,7 @@ public static class SecretCommands
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error opening store: {ex.Message}");
logger.LogError(ex, "Failed to open store");
return (null, null);
}
}
@@ -2,6 +2,7 @@ using System.CommandLine;
using JdeScoping.ConfigManager.Core.Models;
using JdeScoping.ConfigManager.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -90,68 +91,73 @@ public static class TestConnectionCommand
string? connectionName,
ConnectionProvider provider)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("TestConnectionCommand");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var connectionTestService = serviceProvider.GetRequiredService<IConnectionTestService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
["Command"] = $"test-connection {provider.ToString().ToLower()}",
["ConfigPath"] = configPath ?? "(default)",
["ConnectionName"] = connectionName ?? "(auto)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var entry = connectionName != null
? config.ConnectionStrings.Entries.FirstOrDefault(e =>
e.Name.Equals(connectionName, StringComparison.OrdinalIgnoreCase))
: config.ConnectionStrings.Entries.FirstOrDefault(e => e.Provider == provider);
if (entry == null)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
var message = connectionName != null
? $"Connection '{connectionName}' not found"
: $"No {provider} connection found";
Console.Error.WriteLine($"Error: {message}");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
if (!quiet)
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var connectionTestService = serviceProvider.GetRequiredService<IConnectionTestService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
Console.WriteLine($"Testing connection: {entry.Name}");
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
var connectionString = entry.GenerateConnectionString();
var result = await connectionTestService.TestConnectionAsync(connectionString, entry.Provider);
if (result.Success)
try
{
if (!quiet)
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var entry = connectionName != null
? config.ConnectionStrings.Entries.FirstOrDefault(e =>
e.Name.Equals(connectionName, StringComparison.OrdinalIgnoreCase))
: config.ConnectionStrings.Entries.FirstOrDefault(e => e.Provider == provider);
if (entry == null)
{
Console.WriteLine($"Status: Success");
if (result.Duration.HasValue)
Console.WriteLine($"Duration: {result.Duration.Value.TotalMilliseconds:F0}ms");
var message = connectionName != null
? $"Connection '{connectionName}' not found"
: $"No {provider} connection found";
logger.LogError("{Message}", message);
return 1;
}
return 0;
}
Console.Error.WriteLine($"Status: Failed");
Console.Error.WriteLine($"Message: {result.Message}");
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
logger.LogInformation("Testing connection: {Name}", entry.Name);
var connectionString = entry.GenerateConnectionString();
var result = await connectionTestService.TestConnectionAsync(connectionString, entry.Provider);
if (result.Success)
{
logger.LogInformation("Status: Success");
if (result.Duration.HasValue)
logger.LogInformation("Duration: {Duration}ms", result.Duration.Value.TotalMilliseconds.ToString("F0"));
return 0;
}
logger.LogError("Status: Failed");
logger.LogError("Message: {Message}", result.Message);
return 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to test connection");
return 1;
}
}
}
@@ -161,70 +167,73 @@ public static class TestConnectionCommand
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("TestConnectionCommand");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var connectionTestService = serviceProvider.GetRequiredService<IConnectionTestService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
["Command"] = "test-connection all",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var hasFailures = false;
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine("=== Testing All Connections ===");
Console.WriteLine($"Found {config.ConnectionStrings.Entries.Count} connection(s)");
Console.WriteLine();
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
foreach (var entry in config.ConnectionStrings.Entries)
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var connectionTestService = serviceProvider.GetRequiredService<IConnectionTestService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
if (entry.Provider == ConnectionProvider.Generic)
{
if (!quiet)
Console.WriteLine($"{entry.Name}: Skipped (generic provider)");
continue;
}
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
var connectionString = entry.GenerateConnectionString();
var result = await connectionTestService.TestConnectionAsync(connectionString, entry.Provider);
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var hasFailures = false;
if (result.Success)
logger.LogInformation("=== Testing All Connections ===");
logger.LogInformation("Found {Count} connection(s)", config.ConnectionStrings.Entries.Count);
logger.LogInformation("");
foreach (var entry in config.ConnectionStrings.Entries)
{
if (!quiet)
if (entry.Provider == ConnectionProvider.Generic)
{
logger.LogInformation("{Name}: Skipped (generic provider)", entry.Name);
continue;
}
var connectionString = entry.GenerateConnectionString();
var result = await connectionTestService.TestConnectionAsync(connectionString, entry.Provider);
if (result.Success)
{
var duration = result.Duration.HasValue
? $" ({result.Duration.Value.TotalMilliseconds:F0}ms)"
: "";
Console.WriteLine($"{entry.Name}: OK{duration}");
logger.LogInformation("{Name}: OK{Duration}", entry.Name, duration);
}
else
{
hasFailures = true;
logger.LogError("{Name}: FAILED - {Message}", entry.Name, result.Message);
}
}
else
{
hasFailures = true;
Console.Error.WriteLine($"{entry.Name}: FAILED - {result.Message}");
}
}
return hasFailures ? 1 : 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
return hasFailures ? 1 : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to test connections");
return 1;
}
}
}
@@ -1,6 +1,7 @@
using System.CommandLine;
using JdeScoping.ConfigManager.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace JdeScoping.ConfigManager.Cli.Commands;
@@ -96,58 +97,63 @@ public static class ValidateCommand
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ValidateCommand");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var validationService = serviceProvider.GetRequiredService<IValidationService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
["Command"] = "validate appsettings",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.Error.WriteLine($"Error: appsettings.json not found at {appSettingsPath}");
return 1;
}
try
{
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var result = validationService.ValidateAppSettings(config);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine("=== AppSettings Validation ===");
Console.WriteLine($"File: {appSettingsPath}");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
if (result.IsValid)
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var validationService = serviceProvider.GetRequiredService<IValidationService>();
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
if (!quiet)
Console.WriteLine("Status: Valid");
return 0;
logger.LogError("appsettings.json not found at {Path}", appSettingsPath);
return 1;
}
foreach (var error in result.Errors)
try
{
Console.Error.WriteLine($"Error: {error}");
}
var config = await configFileService.LoadAppSettingsAsync(appSettingsPath);
var result = validationService.ValidateAppSettings(config);
foreach (var warning in result.Warnings)
logger.LogInformation("=== AppSettings Validation ===");
logger.LogInformation("File: {Path}", appSettingsPath);
if (result.IsValid)
{
logger.LogInformation("Status: Valid");
return 0;
}
foreach (var error in result.Errors)
{
logger.LogError("{Error}", error);
}
foreach (var warning in result.Warnings)
{
logger.LogWarning("{Warning}", warning);
}
return 1;
}
catch (ConfigLoadException ex)
{
if (!quiet)
Console.WriteLine($"Warning: {warning}");
logger.LogError(ex, "Failed to load configuration");
return 1;
}
return 1;
}
catch (ConfigLoadException ex)
{
Console.Error.WriteLine($"Error loading configuration: {ex.Message}");
return 1;
}
}
@@ -157,54 +163,59 @@ public static class ValidateCommand
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ValidateCommand");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var validationService = serviceProvider.GetRequiredService<IValidationService>();
var pipelinesDir = Path.Combine(folderPath, "Pipelines");
try
["Command"] = "validate pipelines",
["ConfigPath"] = configPath ?? "(default)"
}))
{
var pipelines = await configFileService.LoadAllPipelinesAsync(pipelinesDir);
var result = validationService.ValidatePipelines(pipelines);
if (!quiet)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
Console.WriteLine("=== Pipelines Validation ===");
Console.WriteLine($"Directory: {pipelinesDir}");
Console.WriteLine($"Pipelines found: {pipelines.Count}");
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
if (result.IsValid)
{
if (!quiet)
Console.WriteLine("Status: Valid");
return 0;
}
var configFileService = serviceProvider.GetRequiredService<IConfigFileService>();
var validationService = serviceProvider.GetRequiredService<IValidationService>();
foreach (var error in result.Errors)
{
Console.Error.WriteLine($"Error: {error}");
}
var pipelinesDir = Path.Combine(folderPath, "Pipelines");
foreach (var warning in result.Warnings)
try
{
if (!quiet)
Console.WriteLine($"Warning: {warning}");
}
var pipelines = await configFileService.LoadAllPipelinesAsync(pipelinesDir);
var result = validationService.ValidatePipelines(pipelines);
return result.Errors.Count > 0 ? 1 : 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error validating pipelines: {ex.Message}");
return 1;
logger.LogInformation("=== Pipelines Validation ===");
logger.LogInformation("Directory: {Path}", pipelinesDir);
logger.LogInformation("Pipelines found: {Count}", pipelines.Count);
if (result.IsValid)
{
logger.LogInformation("Status: Valid");
return 0;
}
foreach (var error in result.Errors)
{
logger.LogError("{Error}", error);
}
foreach (var warning in result.Warnings)
{
logger.LogWarning("{Warning}", warning);
}
return result.Errors.Count > 0 ? 1 : 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to validate pipelines");
return 1;
}
}
}
@@ -214,48 +225,54 @@ public static class ValidateCommand
bool verbose,
bool quiet)
{
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("ValidateCommand");
using (logger.BeginScope(new Dictionary<string, object>
{
Console.Error.WriteLine("Error: Could not find configuration folder. Use --config-path to specify.");
return 1;
}
var runtimeValidationService = serviceProvider.GetRequiredService<IRuntimeConfigValidationService>();
if (!quiet)
["Command"] = "validate runtime",
["ConfigPath"] = configPath ?? "(default)"
}))
{
Console.WriteLine("=== Runtime Configuration Validation ===");
Console.WriteLine($"Folder: {folderPath}");
}
var results = runtimeValidationService.ValidateRuntimeConfig(folderPath);
var hasErrors = false;
foreach (var result in results)
{
if (result.Errors.Count > 0)
var folderPath = await GetConfigFolderAsync(serviceProvider, configPath);
if (folderPath == null)
{
hasErrors = true;
Console.Error.WriteLine($"\n[{result.ValidatorName}]");
foreach (var error in result.Errors)
logger.LogError("Could not find configuration folder. Use --config-path to specify");
return 1;
}
var runtimeValidationService = serviceProvider.GetRequiredService<IRuntimeConfigValidationService>();
logger.LogInformation("=== Runtime Configuration Validation ===");
logger.LogInformation("Folder: {Path}", folderPath);
var results = runtimeValidationService.ValidateRuntimeConfig(folderPath);
var hasErrors = false;
foreach (var result in results)
{
if (result.Errors.Count > 0)
{
Console.Error.WriteLine($" Error: {error}");
hasErrors = true;
logger.LogError("[{ValidatorName}]", result.ValidatorName);
foreach (var error in result.Errors)
{
logger.LogError(" {Error}", error);
}
}
else
{
logger.LogInformation("[{ValidatorName}]: OK", result.ValidatorName);
}
foreach (var warning in result.Warnings)
{
logger.LogWarning(" {Warning}", warning);
}
}
else if (!quiet)
{
Console.WriteLine($"[{result.ValidatorName}]: OK");
}
foreach (var warning in result.Warnings)
{
if (!quiet)
Console.WriteLine($" Warning: {warning}");
}
return hasErrors ? 1 : 0;
}
return hasErrors ? 1 : 0;
}
private static async Task<string?> GetConfigFolderAsync(IServiceProvider serviceProvider, string? configPath)
@@ -10,7 +10,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.*" />
<PackageReference Include="Serilog" Version="4.*" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.*" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.*" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
@@ -2,7 +2,8 @@ using System.CommandLine;
using JdeScoping.ConfigManager.Cli.Commands;
using JdeScoping.ConfigManager.Core.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Events;
namespace JdeScoping.ConfigManager.Cli;
@@ -18,8 +19,12 @@ public static class Program
/// <returns>Exit code: 0 for success, non-zero for errors.</returns>
public static async Task<int> Main(string[] args)
{
// Pre-parse verbose/quiet flags for logging configuration
bool verbose = args.Contains("-v") || args.Contains("--verbose");
bool quiet = args.Contains("-q") || args.Contains("--quiet");
var services = new ServiceCollection();
ConfigureServices(services);
ConfigureServices(services, verbose, quiet);
using var serviceProvider = services.BuildServiceProvider();
var rootCommand = new RootCommand("JDE Scoping Tool configuration management CLI")
@@ -101,15 +106,34 @@ public static class Program
connectionCommand.AddCommand(ConnectionCommands.CreateRemoveCommand(serviceProvider, configPathOption, verboseOption, quietOption));
rootCommand.AddCommand(connectionCommand);
return await rootCommand.InvokeAsync(args);
try
{
return await rootCommand.InvokeAsync(args);
}
finally
{
await Log.CloseAndFlushAsync();
}
}
private static void ConfigureServices(IServiceCollection services)
private static void ConfigureServices(IServiceCollection services, bool verbose, bool quiet)
{
services.AddLogging(builder => builder
.AddConsole()
.SetMinimumLevel(LogLevel.Warning));
// Log level mapping:
// --quiet: Warning level (hide success/info messages, only show warnings and errors)
// default: Information level (show success messages)
// --verbose: Debug level (show all messages including debug details)
var logLevel = quiet ? LogEventLevel.Warning
: verbose ? LogEventLevel.Debug
: LogEventLevel.Information;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(logLevel)
.WriteTo.Console(
outputTemplate: "{Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext()
.CreateLogger();
services.AddLogging(builder => builder.AddSerilog(dispose: true));
services.AddConfigManagerCore();
}
}
@@ -0,0 +1,203 @@
# ConfigManager CLI Logging Style Guide
This document describes the logging approach used in the ConfigManager CLI application.
## Architecture
The CLI uses **Microsoft.Extensions.Logging** as the logging abstraction with **Serilog** as the provider. This provides:
- Structured logging with semantic parameter names
- Flexible log level control via command-line flags
- Clean, message-only console output for CLI tooling
## Configuration
### Dependency Injection Setup
Logging is configured via dependency injection in `Program.cs`. Serilog is registered with the DI container:
```csharp
private static void ConfigureServices(IServiceCollection services, bool verbose, bool quiet)
{
// Determine log level from command-line flags
var logLevel = quiet ? LogEventLevel.Warning
: verbose ? LogEventLevel.Debug
: LogEventLevel.Information;
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(logLevel)
.WriteTo.Console(outputTemplate: "{Message:lj}{NewLine}{Exception}")
.Enrich.FromLogContext()
.CreateLogger();
// Register logging with DI container
services.AddLogging(builder => builder.AddSerilog(dispose: true));
// Register other services...
}
```
This approach:
- Creates a Serilog logger configured for CLI output
- Registers it with `Microsoft.Extensions.Logging` via `AddSerilog()`
- Makes `ILoggerFactory` available to command handlers through the service provider
### Log Level Mapping
The CLI supports three verbosity modes controlled by command-line flags:
| Flag | Log Level | Visible Messages |
|------|-----------|------------------|
| `--quiet` / `-q` | Warning | Warnings and errors only |
| (default) | Information | Success messages, data output, warnings, errors |
| `--verbose` / `-v` | Debug | All messages including debug details |
### Output Format
The Serilog output template is configured for clean CLI output:
```
{Message:lj}{NewLine}{Exception}
```
This produces message-only output without timestamps or log levels, suitable for command-line tools.
## Logger Injection Pattern
Command handlers obtain a logger from the service provider:
```csharp
private static async Task<int> SomeCommandAsync(
IServiceProvider serviceProvider,
string? configPath,
bool verbose,
bool quiet)
{
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("CommandClassName");
// Command implementation using logger
}
```
## Structured Logging Patterns
### Log Scope with Context Properties
Wrap command logic in a log scope to add context:
```csharp
using (logger.BeginScope(new Dictionary<string, object>
{
["Command"] = "secret set",
["ConfigPath"] = configPath ?? "(default)",
["SecretKey"] = key
}))
{
// All log messages within this block include the scope properties
}
```
### Standard Context Properties
| Property | Description | Example |
|----------|-------------|---------|
| `Command` | Full command path | `"secret list"`, `"config set datasync"` |
| `ConfigPath` | Configuration folder path | `"/app/config"` or `"(default)"` |
| `ConnectionName` | Connection name for connection commands | `"jde"` |
| `PipelineName` | Pipeline name for pipeline commands | `"WorkOrder"` |
| `BackupTimestamp` | Timestamp for backup operations | `"2026-01-15_120000"` |
### Message Templates
Use semantic parameter names in message templates:
```csharp
// Good - uses named parameters
logger.LogInformation("Secret '{Key}' set successfully", key);
logger.LogError("Connection '{Name}' not found", name);
// Avoid - positional or embedded values
logger.LogInformation($"Secret '{key}' set successfully");
```
## Log Level Guidelines
### Information Level
Use for:
- Success messages
- Data output (lists, tables, JSON)
- Section headers
```csharp
logger.LogInformation("Secret '{Key}' set successfully", key);
logger.LogInformation("=== Connections ({Count}) ===", entries.Count);
logger.LogInformation("{Data}", JsonSerializer.Serialize(section, options));
```
### Debug Level
Use for:
- Verbose details (shown with `--verbose`)
- Intermediate progress updates
- Detailed configuration values
```csharp
logger.LogDebug("Backup created: {BackupName}", Path.GetFileName(backupPath));
logger.LogDebug("LookbackMultiplier: {Value}", section.LookbackMultiplier);
```
### Warning Level
Use for:
- Non-critical issues
- Validation warnings
```csharp
logger.LogWarning("{Warning}", warning);
```
### Error Level
Use for:
- Operation failures
- Missing required files
- Validation errors
- Exceptions
```csharp
logger.LogError("Could not find configuration folder. Use --config-path to specify");
logger.LogError("Connection '{Name}' not found", name);
logger.LogError(ex, "Failed to set secret");
```
## Converting from Console.WriteLine
| Pattern | Before | After |
|---------|--------|-------|
| Success message | `Console.WriteLine($"Secret '{key}' set successfully");` | `logger.LogInformation("Secret '{Key}' set successfully", key);` |
| Error message | `Console.Error.WriteLine($"Error: {ex.Message}");` | `logger.LogError(ex, "Operation failed");` |
| Data output | `Console.WriteLine(JsonSerializer.Serialize(...));` | `logger.LogInformation("{Data}", JsonSerializer.Serialize(...));` |
| Table row | `Console.WriteLine($"{name,-25} {value,-15}");` | `logger.LogInformation("{Name,-25} {Value,-15}", name, value);` |
| Verbose detail | `if (verbose) Console.WriteLine(...);` | `logger.LogDebug(...);` |
| Quiet guard | `if (!quiet) Console.WriteLine(...);` | `logger.LogInformation(...);` (level handles filtering) |
## Interactive Prompts
Keep `Console.Write` for interactive prompts that require user input:
```csharp
// This stays as Console.Write - interactive prompt
Console.Write($"Delete pipeline '{name}'? [y/N] ");
var response = Console.ReadLine();
```
## Best Practices
1. **Always use structured parameters** - Never embed values directly in message strings
2. **Use consistent context properties** - Follow the standard property names above
3. **Let log levels handle filtering** - Don't add manual `if (!quiet)` guards
4. **Include exceptions in error logs** - Use `logger.LogError(ex, "Message")` to capture stack traces
5. **Keep messages actionable** - Error messages should help users understand what to do next
@@ -23,6 +23,7 @@ public class BackupCommandsTests
var services = new ServiceCollection();
services.AddSingleton(_backupService);
services.AddSingleton(_autoDiscoveryService);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -272,10 +273,10 @@ public class BackupCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -288,7 +289,7 @@ public class BackupCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -27,6 +27,7 @@ public class ConfigCommandsTests
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
services.AddSingleton(_backupService);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -366,10 +367,10 @@ public class ConfigCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -382,7 +383,7 @@ public class ConfigCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -24,6 +24,7 @@ public class ConnectionCommandsTests
var services = new ServiceCollection();
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -219,10 +220,10 @@ public class ConnectionCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -235,7 +236,7 @@ public class ConnectionCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -331,10 +332,10 @@ public class ConnectionCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -347,7 +348,7 @@ public class ConnectionCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -393,10 +394,10 @@ public class ConnectionCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -409,7 +410,7 @@ public class ConnectionCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -478,10 +479,10 @@ public class ConnectionCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -494,7 +495,7 @@ public class ConnectionCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -24,6 +24,7 @@ public class PipelineCommandsTests
var services = new ServiceCollection();
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -28,6 +28,7 @@ public class SecretCommandsTests
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
services.AddSingleton(_secureStoreManager);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -169,10 +170,10 @@ public class SecretCommandsTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -185,7 +186,7 @@ public class SecretCommandsTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -27,6 +27,7 @@ public class TestConnectionCommandTests
services.AddSingleton(_configFileService);
services.AddSingleton(_autoDiscoveryService);
services.AddSingleton(_connectionTestService);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -243,10 +244,10 @@ public class TestConnectionCommandTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -259,7 +260,7 @@ public class TestConnectionCommandTests
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -31,6 +31,7 @@ public class ValidateCommandTests
services.AddSingleton(_autoDiscoveryService);
services.AddSingleton(_validationService);
services.AddSingleton(_runtimeValidationService);
services.AddTestLogging();
_serviceProvider = services.BuildServiceProvider();
_configPathOption = new Option<string?>(["--config-path", "-c"]);
@@ -193,10 +194,10 @@ public class ValidateCommandTests
rootCommand.AddGlobalOption(_verboseOption);
rootCommand.AddGlobalOption(_quietOption);
// Capture console error output
var originalErr = Console.Error;
// Capture console output (Serilog writes all output to stdout)
var originalOut = Console.Out;
using var writer = new StringWriter();
Console.SetError(writer);
Console.SetOut(writer);
try
{
@@ -205,11 +206,11 @@ public class ValidateCommandTests
// Assert
var output = writer.ToString();
output.ShouldContain("Error");
output.ShouldContain("Connection string 'LocalCache' is required");
}
finally
{
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
}
finally
@@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Events;
namespace JdeScoping.ConfigManager.Cli.Tests;
/// <summary>
/// Helper for setting up logging in CLI tests.
/// </summary>
public static class TestLoggingHelper
{
/// <summary>
/// Adds Serilog logging to a service collection configured to write to Console.
/// This allows tests to capture log output via Console.SetOut.
/// </summary>
public static IServiceCollection AddTestLogging(this IServiceCollection services, LogEventLevel level = LogEventLevel.Information)
{
var logger = new LoggerConfiguration()
.MinimumLevel.Is(level)
.WriteTo.Console(outputTemplate: "{Message:lj}{NewLine}{Exception}")
.CreateLogger();
services.AddLogging(builder => builder.AddSerilog(logger, dispose: true));
return services;
}
}