using MemberCenter.Domain.Entities; using MemberCenter.Infrastructure.Configuration; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Npgsql; using System.Text.Json; using System.Text.Json.Nodes; using System.CommandLine; EnvLoader.LoadDotEnvIfDevelopment(); var root = new RootCommand("Member Center installer"); var connectionStringOption = new Option( name: "--connection-string", description: "Database connection string"); connectionStringOption.AddAlias("-c"); var appsettingsOption = new Option( name: "--appsettings", description: "Path to appsettings.json"); var noPromptOption = new Option( name: "--no-prompt", description: "Disable interactive prompts"); var verboseOption = new Option( name: "--verbose", description: "Enable verbose output"); var forceOption = new Option( name: "--force", description: "Force execution even if already installed"); var adminEmailOption = new Option( name: "--admin-email", description: "Admin email"); var adminPasswordOption = new Option( name: "--admin-password", description: "Admin password"); var adminDisplayNameOption = new Option( name: "--admin-display-name", description: "Admin display name (optional)"); var targetMigrationOption = new Option( name: "--target", description: "Target migration"); var initCommand = new Command("init", "Initialize database (migrate + seed + admin)"); initCommand.AddOption(connectionStringOption); initCommand.AddOption(appsettingsOption); initCommand.AddOption(noPromptOption); initCommand.AddOption(verboseOption); initCommand.AddOption(forceOption); initCommand.AddOption(adminEmailOption); initCommand.AddOption(adminPasswordOption); initCommand.AddOption(adminDisplayNameOption); initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) => { var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt); if (string.IsNullOrWhiteSpace(resolvedConnection)) { Console.Error.WriteLine("Connection string is required."); return; } var services = BuildServices(resolvedConnection); await using var scope = services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var userManager = scope.ServiceProvider.GetRequiredService>(); var roleManager = scope.ServiceProvider.GetRequiredService>(); if (!force && await IsInstalledAsync(db)) { Console.Error.WriteLine("System already installed. Use --force to override."); return; } if (!noPrompt) { adminEmail ??= Prompt("Admin email", "admin@example.com"); adminPassword ??= PromptSecret("Admin password"); } adminEmail ??= "admin@example.com"; if (string.IsNullOrWhiteSpace(adminPassword)) { Console.Error.WriteLine("Admin password is required."); return; } await db.Database.MigrateAsync(); await EnsureRoleAsync(roleManager, "admin"); await EnsureRoleAsync(roleManager, "support"); var admin = await userManager.FindByEmailAsync(adminEmail); if (admin is null) { admin = new ApplicationUser { Id = Guid.NewGuid(), UserName = adminEmail, Email = adminEmail, EmailConfirmed = true }; var createResult = await userManager.CreateAsync(admin, adminPassword); if (!createResult.Succeeded) { Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description))); return; } } if (!await userManager.IsInRoleAsync(admin, "admin")) { await userManager.AddToRoleAsync(admin, "admin"); } await SetInstalledFlagAsync(db); if (verbose) { Console.WriteLine("Init completed."); } }, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption); var addAdminCommand = new Command("add-admin", "Add admin user"); addAdminCommand.AddOption(connectionStringOption); addAdminCommand.AddOption(appsettingsOption); addAdminCommand.AddOption(noPromptOption); addAdminCommand.AddOption(adminEmailOption); addAdminCommand.AddOption(adminPasswordOption); addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) => { var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt); if (string.IsNullOrWhiteSpace(resolvedConnection)) { Console.Error.WriteLine("Connection string is required."); return; } if (!noPrompt) { adminEmail ??= Prompt("Admin email", "admin@example.com"); adminPassword ??= PromptSecret("Admin password"); } adminEmail ??= "admin@example.com"; if (string.IsNullOrWhiteSpace(adminPassword)) { Console.Error.WriteLine("Admin password is required."); return; } var services = BuildServices(resolvedConnection); await using var scope = services.CreateAsyncScope(); var userManager = scope.ServiceProvider.GetRequiredService>(); var roleManager = scope.ServiceProvider.GetRequiredService>(); await EnsureRoleAsync(roleManager, "admin"); var admin = await userManager.FindByEmailAsync(adminEmail); if (admin is null) { admin = new ApplicationUser { Id = Guid.NewGuid(), UserName = adminEmail, Email = adminEmail, EmailConfirmed = true }; var createResult = await userManager.CreateAsync(admin, adminPassword); if (!createResult.Succeeded) { Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description))); return; } } if (!await userManager.IsInRoleAsync(admin, "admin")) { await userManager.AddToRoleAsync(admin, "admin"); } Console.WriteLine("Admin user ready."); }, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption); var resetCommand = new Command("reset-admin-password", "Reset admin password"); resetCommand.AddOption(connectionStringOption); resetCommand.AddOption(appsettingsOption); resetCommand.AddOption(noPromptOption); resetCommand.AddOption(adminEmailOption); resetCommand.AddOption(adminPasswordOption); resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) => { var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt); if (string.IsNullOrWhiteSpace(resolvedConnection)) { Console.Error.WriteLine("Connection string is required."); return; } if (!noPrompt) { adminEmail ??= Prompt("Admin email", "admin@example.com"); adminPassword ??= PromptSecret("New password"); } adminEmail ??= "admin@example.com"; if (string.IsNullOrWhiteSpace(adminPassword)) { Console.Error.WriteLine("Admin password is required."); return; } var services = BuildServices(resolvedConnection); await using var scope = services.CreateAsyncScope(); var userManager = scope.ServiceProvider.GetRequiredService>(); var admin = await userManager.FindByEmailAsync(adminEmail); if (admin is null) { Console.Error.WriteLine("Admin not found."); return; } var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin); var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword); if (!result.Succeeded) { Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description))); return; } Console.WriteLine("Password reset completed."); }, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption); var migrateCommand = new Command("migrate", "Run migrations only"); migrateCommand.AddOption(connectionStringOption); migrateCommand.AddOption(appsettingsOption); migrateCommand.AddOption(targetMigrationOption); migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) => { var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true); if (string.IsNullOrWhiteSpace(resolvedConnection)) { Console.Error.WriteLine("Connection string is required."); return; } var services = BuildServices(resolvedConnection); await using var scope = services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); if (string.IsNullOrWhiteSpace(target)) { await db.Database.MigrateAsync(); } else { var migrator = scope.ServiceProvider.GetRequiredService(); await migrator.MigrateAsync(target); } Console.WriteLine("Migrations completed."); }, connectionStringOption, appsettingsOption, targetMigrationOption); root.AddCommand(initCommand); root.AddCommand(addAdminCommand); root.AddCommand(resetCommand); root.AddCommand(migrateCommand); return await root.InvokeAsync(args); static IServiceProvider BuildServices(string connectionString) { var services = new ServiceCollection(); services.AddLogging(builder => builder.AddConsole()); services.AddDbContext(options => { options.UseNpgsql(connectionString); options.UseOpenIddict(); }); services.AddIdentity(options => { options.User.RequireUniqueEmail = true; options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 8; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); return services.BuildServiceProvider(); } static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt) { var targetPath = ResolveAppsettingsPath(appsettingsPath); var envConnection = Environment.GetEnvironmentVariable("ConnectionStrings__Default") ?? Environment.GetEnvironmentVariable("MEMBERCENTER_CONNECTION"); if (!string.IsNullOrWhiteSpace(envConnection)) { return envConnection; } if (!string.IsNullOrWhiteSpace(connectionString)) { WriteConnectionString(targetPath, connectionString); return connectionString; } if (File.Exists(targetPath)) { var config = new ConfigurationBuilder() .AddJsonFile(targetPath, optional: true) .Build(); var existing = config.GetConnectionString("Default"); if (!string.IsNullOrWhiteSpace(existing)) { return existing; } } if (noPrompt) { return null; } var host = Prompt("DB host", "localhost"); var database = Prompt("DB name", "member_center"); var username = Prompt("DB user", "postgres"); var password = PromptSecret("DB password"); var generated = $"Host={host};Database={database};Username={username};Password={password}"; WriteConnectionString(targetPath, generated); return generated; } static string ResolveAppsettingsPath(string? appsettingsPath) { if (!string.IsNullOrWhiteSpace(appsettingsPath)) { return appsettingsPath; } var apiDefault = Path.Combine("src", "MemberCenter.Api", "appsettings.json"); if (File.Exists(apiDefault)) { return apiDefault; } return "appsettings.json"; } static void WriteConnectionString(string path, string connectionString) { JsonNode root; if (File.Exists(path)) { var json = File.ReadAllText(path); root = JsonNode.Parse(json) ?? new JsonObject(); } else { root = new JsonObject(); } var obj = root as JsonObject ?? new JsonObject(); var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject(); connectionStrings["Default"] = connectionString; obj["ConnectionStrings"] = connectionStrings; var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(path, obj.ToJsonString(options)); } static async Task IsInstalledAsync(MemberCenterDbContext db) { try { var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed"); return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase); } catch (PostgresException ex) when (ex.SqlState == "42P01") { return false; } } static async Task SetInstalledFlagAsync(MemberCenterDbContext db) { var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed"); if (flag is null) { db.SystemFlags.Add(new SystemFlag { Id = Guid.NewGuid(), Key = "installed", Value = "true", UpdatedAt = DateTimeOffset.UtcNow }); } else { flag.Value = "true"; flag.UpdatedAt = DateTimeOffset.UtcNow; } await db.SaveChangesAsync(); } static async Task EnsureRoleAsync(RoleManager roleManager, string roleName) { if (!await roleManager.RoleExistsAsync(roleName)) { var result = await roleManager.CreateAsync(new ApplicationRole { Id = Guid.NewGuid(), Name = roleName, NormalizedName = roleName.ToUpperInvariant() }); if (!result.Succeeded) { throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description))); } } } static string Prompt(string label, string defaultValue) { Console.Write($"{label} [{defaultValue}]: "); var input = Console.ReadLine(); return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim(); } static string PromptSecret(string label) { Console.Write($"{label}: "); var password = string.Empty; ConsoleKeyInfo key; while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter) { if (key.Key == ConsoleKey.Backspace && password.Length > 0) { password = password[..^1]; Console.Write("\b \b"); continue; } if (!char.IsControl(key.KeyChar)) { password += key.KeyChar; Console.Write("*"); } } Console.WriteLine(); return password; }