From ac2b6aba6ea897e972922fd6ede926591b74ffbc Mon Sep 17 00:00:00 2001 From: sherl Date: Wed, 17 Dec 2025 01:07:05 +0100 Subject: [PATCH] feat: add support for applying migrations automatically --- Data/ApplicationDbContext.cs | 2 - Program.cs | 2 +- Tools/Cli.cs | 37 +++++++++++++----- Tools/Seeder.cs | 73 ++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/Data/ApplicationDbContext.cs b/Data/ApplicationDbContext.cs index 0702ec0..2108b19 100644 --- a/Data/ApplicationDbContext.cs +++ b/Data/ApplicationDbContext.cs @@ -1,7 +1,5 @@ using Microsoft.EntityFrameworkCore; using Shadow.Entities; -using System.Diagnostics.Metrics; -using System.Reflection.Emit; namespace Shadow.Data; public class ApplicationDbContext : DbContext diff --git a/Program.cs b/Program.cs index 7b17bb7..77dec05 100644 --- a/Program.cs +++ b/Program.cs @@ -78,7 +78,7 @@ if (args.FirstOrDefault() is not null) ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService(); Cli cli = new(db, guhf, args); - shutdown = await cli.Parse(); + shutdown = cli.Parse(); } if (shutdown) return; } diff --git a/Tools/Cli.cs b/Tools/Cli.cs index c1eee2c..6a09375 100644 --- a/Tools/Cli.cs +++ b/Tools/Cli.cs @@ -17,7 +17,7 @@ public class Cli args = _args; } - public async Task Parse() + public bool Parse() { // Returns true if the program can finish execution. bool exit = true; @@ -30,16 +30,19 @@ public class Cli case "adduser": case "--adduser": case "--add-user": + if (!Seeder.EnsureMigrations(db)) return true; AddUser(); break; case "resetpassword": case "--resetpassword": case "--reset-password": + if (!Seeder.EnsureMigrations(db)) return true; ResetPassword(); break; case "removeuser": case "--removeuser": case "--remove-user": + if (!Seeder.EnsureMigrations(db)) return true; RemoveUser(); break; case "help": @@ -49,6 +52,7 @@ public class Cli case "/h": case "/?": case "?": + // Help should be shown without forcing migrations. ShowHelp(); break; case "setupwizard": @@ -58,6 +62,7 @@ public class Cli case "--setup": case "configure": case "--configure": + if (!Seeder.EnsureMigrations(db)) return true; SetupWizard(); break; default: @@ -73,13 +78,21 @@ public class Cli // Shown when "Shadow --help"/"Shadow help"/... is ran. Console.WriteLine( $"--- Shadow commandline utility ---\n" + - $"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n\n" + + $"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n" + + $"\n" + $"Available commands:\n" + + $"\n" + + $"=== User management ===\n" + $"- addUser [username] - create a new user,\n" + $"- resetPassword [username] - reset a user's password,\n" + - $"- removeUser [username] - remove the user COMPLETELY.\n\n" + + $"- removeUser [username] - remove the user COMPLETELY.\n" + + $"\n" + + $"=== Server maintenance ===\n" + + $"- setupWizard - configure library and thumbnail location on disk.\n" + + $"\n" + $"Username is optional. If not provided, user will be prompted to enter it.\n" + - $"Running without specifying a command launches a Kestrel web server.\n\n" + + $"Running without specifying a command launches a Kestrel web server.\n" + + $"\n" + $"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow" ); } @@ -271,7 +284,8 @@ public class Cli Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath"); Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath"); - if (musicLibraryPath is not null || musicThumbnailPath is not null) + bool configurationExists = musicLibraryPath is not null || musicThumbnailPath is not null; + if (configurationExists) { Console.WriteLine(" Found existing configuration:"); if (musicLibraryPath is not null) @@ -314,12 +328,17 @@ public class Cli Console.WriteLine("success!"); success = true; + + Console.WriteLine("\n" + + "Tip: On your next run of Shadow, a web server will start.\n" + + "If you ever need to enter setup wizard again, use `Shadow setupWizard`.\n" + + "For a complete list of available commands use `Shadow help`.\n"); } return success; } - public string ReadPassword(string prompt = " Enter password (will not be echoed back): ") + public static string ReadPassword(string prompt = " Enter password (will not be echoed back): ") { // https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/ string password = String.Empty; @@ -344,14 +363,14 @@ public class Cli return password; } - public string? ReadName(string prompt = " Enter username: ") + public static string? ReadName(string prompt = " Enter username: ") { Console.Write(prompt); string? input = Console.ReadLine(); return input; } - public bool YesNoPrompt(string prompt, bool? default_value = null) + public static bool YesNoPrompt(string prompt, bool? default_value = null) { // Checks if user input starts with "y", and if it does, returns true. // Otherwise checks for "n". If both checks fail, and default_value is null, @@ -388,7 +407,7 @@ public class Cli return response; } - public string DefaultPrompt(string prompt, string? default_value = null) + public static string DefaultPrompt(string prompt, string? default_value = null) { // Prompt the user repeatedly for answer. If default value // is specified, an enter will be treated as if the user diff --git a/Tools/Seeder.cs b/Tools/Seeder.cs index f63f788..4c56b18 100644 --- a/Tools/Seeder.cs +++ b/Tools/Seeder.cs @@ -21,6 +21,10 @@ public class Seeder Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n"); + // Check migration status. If we can't proceed, exit now. + bool migrationSucceeded = EnsureMigrations(db); + if (!migrationSucceeded) return true; + // Check if this is a clean, first run. If so, run the setup wizard. Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion"); Global? runs = db.Globals.FirstOrDefault(g => g.Key == "runs"); @@ -104,4 +108,73 @@ public class Seeder return shutdown; } + + public static bool EnsureMigrations(ApplicationDbContext dbContext) + { + // Returns true if migrations have been successfully applied. + // Otherwise returns false. + bool migrationSuccess = false; + List availableMigrations = dbContext.Database.GetMigrations().ToList(); + List appliedMigrations = dbContext.Database.GetAppliedMigrations().ToList(); + + bool hasMissingMigrations = availableMigrations.Count > appliedMigrations.Count; + bool doMigrationsMatch = availableMigrations.SequenceEqual(appliedMigrations); + if (hasMissingMigrations) + { + bool userMigrationConsent = false; + Console.WriteLine("\n" + + "========================================\n" + + "[Warn] Database migrations missing!\n" + + "========================================"); + if (appliedMigrations.Count == 0) + { + Console.WriteLine( "Empty database detected. Applying migrations is recommended, and required if this is the first run of Shadow."); + userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [Y/n]: ", true); + } + else + { + Console.WriteLine($"Detected existing {appliedMigrations.Count} migrations. Backing up the database before applying migrations is recommended."); + userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [y/N]: ", false); + } + + // Do we have user permission to perform migration? + if (userMigrationConsent) + { + dbContext.Database.Migrate(); + dbContext.SaveChanges(); + } + else + { + Console.WriteLine("Exiting..."); + return migrationSuccess; + } + + // Check if migrations match afterwards + List newlyAppliedMigrations = dbContext.Database.GetAppliedMigrations().ToList(); + bool doNewlyAppliedMigrationsMatch = availableMigrations.SequenceEqual(newlyAppliedMigrations); + if (doNewlyAppliedMigrationsMatch) + { + Console.WriteLine("\n" + + "==============================\n" + + "Migration success!\n" + + "==============================\n"); + migrationSuccess = true; + } + else + { + // An error ocurred when applying migrations automatically + Console.WriteLine("\n" + + "==================================================\n" + + "[Error] Failed applying migrations automatically!\n" + + "==================================================\n"); + Console.WriteLine("The list of newly applied migrations does not match the list of planned migrations.\n" + + "Shadow cannot operate with missing migrations. Please manually revise the database structure and create backups before altering it.\n"); + migrationSuccess = false; + } + } + else migrationSuccess = true; + + return migrationSuccess; + } + }