From ef9499a9bbd21a19cb21eb78c98b51c7388ad03c Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 23 Oct 2026 07:03:27 +0200 Subject: [PATCH 1/4] feat: shellui migrate rewrites pages for template API changes shellui update replaces a component but leaves the app's pages on the old API, so the build breaks. migrate reads the project's .razor and .cs files (not the installed components or build output), shows each changed line and asks before writing; --dry-run only shows, --yes writes without asking and is required outside an interactive terminal. Migrations, from the release notes' upgrade sections: - input: Input.InputVariant.X -> InputVariant.X - date-picker / time-picker: SelectedDate / SelectedTime (with Changed and @bind-) -> Value, on those tags only, so Calendar is untouched - pagination: OnPageChange -> CurrentPageChanged, unless the tag binds CurrentPage - calendar: reports SelectedDateChanged handlers that take a DateTime instead of DateTime?; nothing is rewritten Fixes #58 --- src/ShellUI.CLI/Program.cs | 38 ++++ src/ShellUI.CLI/Services/MigrateService.cs | 201 +++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/ShellUI.CLI/Services/MigrateService.cs diff --git a/src/ShellUI.CLI/Program.cs b/src/ShellUI.CLI/Program.cs index 93a1a43..860b978 100644 --- a/src/ShellUI.CLI/Program.cs +++ b/src/ShellUI.CLI/Program.cs @@ -25,6 +25,7 @@ static async Task Main(string[] args) rootCommand.AddCommand(CreateUpdateCommand()); rootCommand.AddCommand(CreateThemeCommand()); rootCommand.AddCommand(CreateDoctorCommand()); + rootCommand.AddCommand(CreateMigrateCommand()); return await rootCommand.InvokeAsync(args); } @@ -286,6 +287,43 @@ static Command CreateDoctorCommand() return command; } + static Command CreateMigrateCommand() + { + var command = new Command("migrate", "Rewrite your pages for template API changes, showing a diff and asking first"); + + var componentsArg = new Argument( + "components", + $"Component(s) to migrate (empty = all): {string.Join(", ", MigrateService.Components)}") + { + Arity = ArgumentArity.ZeroOrMore + }; + command.AddArgument(componentsArg); + + var dryRunOption = new Option("--dry-run", "Show the changes without writing them"); + command.AddOption(dryRunOption); + + var yesOption = new Option("--yes", "Write the changes without asking"); + command.AddOption(yesOption); + + command.SetHandler(context => + { + try + { + context.ExitCode = MigrateService.Run( + context.ParseResult.GetValueForArgument(componentsArg), + context.ParseResult.GetValueForOption(dryRunOption), + context.ParseResult.GetValueForOption(yesOption)); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); + context.ExitCode = 1; + } + }); + + return command; + } + static Command CreateListCommand() { var command = new Command("list", "List available components"); diff --git a/src/ShellUI.CLI/Services/MigrateService.cs b/src/ShellUI.CLI/Services/MigrateService.cs new file mode 100644 index 0000000..15245cd --- /dev/null +++ b/src/ShellUI.CLI/Services/MigrateService.cs @@ -0,0 +1,201 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using ShellUI.Core.Models; +using Spectre.Console; + +namespace ShellUI.CLI.Services; + +public record MigrationEdit(string Path, string Before, string After, IReadOnlyList Applied); + +public record MigrationNote(string Path, int Line, string Message); + +public record MigrationPlan(IReadOnlyList Edits, IReadOnlyList Notes); + +// Rewrites an app's own .razor and .cs files for template API changes listed in the release notes. +public static class MigrateService +{ + private record Migration(string Component, string Summary, Func Rewrite); + + private static readonly Migration[] Migrations = + { + new("input", "Input.InputVariant.X → InputVariant.X (0.4.0-alpha.3)", + text => text.Replace("Input.InputVariant.", "InputVariant.")), + new("date-picker", "DatePicker SelectedDate → Value (0.4.0-alpha.3)", + text => RewriteTags(text, "DatePicker", tag => RenameParameter(tag, "SelectedDate", "Value"))), + new("time-picker", "TimePicker SelectedTime → Value (0.4.0-alpha.3)", + text => RewriteTags(text, "TimePicker", tag => RenameParameter(tag, "SelectedTime", "Value"))), + new("pagination", "Pagination OnPageChange → CurrentPageChanged (0.4.0-alpha.3)", + text => RewriteTags(text, "Pagination", tag => + // @bind-CurrentPage already sets CurrentPageChanged, so a tag with both can't be renamed. + Regex.IsMatch(tag, @"@bind-CurrentPage\b") ? tag : Regex.Replace(tag, @"(?<=\s)OnPageChange(?=\s*=)", "CurrentPageChanged"))), + }; + + // calendar only reports handlers to fix by hand. + public static IReadOnlyList Components => Migrations.Select(m => m.Component).Append("calendar").Distinct().ToList(); + + public static int Run(string[] components, bool dryRun, bool yes) + { + var unknown = components.Where(c => !Components.Contains(c, StringComparer.OrdinalIgnoreCase)).ToList(); + if (unknown.Count > 0) + { + AnsiConsole.MarkupLine($"[red]No migrations for:[/] {Markup.Escape(string.Join(", ", unknown))}. Migrations exist for {Markup.Escape(string.Join(", ", Components))}."); + return 1; + } + + var root = Directory.GetCurrentDirectory(); + var plan = Plan(root, components); + + foreach (var edit in plan.Edits) + { + AnsiConsole.MarkupLine($"[bold]{Markup.Escape(Path.GetRelativePath(root, edit.Path))}[/] [dim]({Markup.Escape(string.Join("; ", edit.Applied))})[/]"); + foreach (var line in Diff(edit.Before, edit.After)) + AnsiConsole.MarkupLine(line.StartsWith('-') ? $"[red]{Markup.Escape(line)}[/]" : $"[green]{Markup.Escape(line)}[/]"); + AnsiConsole.MarkupLine(""); + } + foreach (var note in plan.Notes) + AnsiConsole.MarkupLine($"[yellow]![/] {Markup.Escape(Path.GetRelativePath(root, note.Path))}:{note.Line} {Markup.Escape(note.Message)}"); + + if (plan.Edits.Count == 0) + { + AnsiConsole.MarkupLine(plan.Notes.Count == 0 ? "[green]Nothing to migrate.[/]" : "[green]Nothing to rewrite.[/] Check the notes above by hand."); + return 0; + } + if (dryRun) + { + AnsiConsole.MarkupLine($"[dim]Dry run: {plan.Edits.Count} file(s) would change.[/]"); + return 0; + } + if (!yes && !AnsiConsole.Profile.Capabilities.Interactive) + { + AnsiConsole.MarkupLine("[yellow]Not an interactive terminal, so nothing was written.[/] Run again with --yes to write the changes."); + return 1; + } + if (!yes && !AnsiConsole.Confirm($"Write these changes to {plan.Edits.Count} file(s)?", false)) + { + AnsiConsole.MarkupLine("[dim]Nothing written.[/]"); + return 0; + } + + foreach (var edit in plan.Edits) + File.WriteAllText(edit.Path, edit.After); + AnsiConsole.MarkupLine($"[green]Migrated {plan.Edits.Count} file(s).[/] Build the app to check."); + return 0; + } + + public static MigrationPlan Plan(string root, IEnumerable? components = null) + { + var wanted = components?.ToList() is { Count: > 0 } list + ? Migrations.Where(m => list.Contains(m.Component, StringComparer.OrdinalIgnoreCase)).ToList() + : Migrations.ToList(); + var checkCalendar = components?.Any() != true || components.Contains("calendar", StringComparer.OrdinalIgnoreCase); + + var edits = new List(); + var notes = new List(); + foreach (var path in SourceFiles(root)) + { + var before = File.ReadAllText(path); + var after = before; + var applied = new List(); + foreach (var migration in wanted) + { + var next = migration.Rewrite(after); + if (next == after) continue; + after = next; + applied.Add(migration.Summary); + } + if (after != before) edits.Add(new MigrationEdit(path, before, after, applied)); + if (checkCalendar) notes.AddRange(CalendarHandlerNotes(path, after)); + } + return new MigrationPlan(edits, notes); + } + + // The app's own sources: not the installed components (update rewrites those) or build output. + private static IEnumerable SourceFiles(string root) + { + var componentsPath = "Components/UI"; + var configPath = Path.Combine(root, "shellui.json"); + if (File.Exists(configPath)) + { + try { componentsPath = JsonSerializer.Deserialize(File.ReadAllText(configPath))?.ComponentsPath ?? componentsPath; } + catch (JsonException) { } + } + var skip = new[] { "bin", "obj", "node_modules", ".shellui", ".git" }; + var componentsDir = Path.GetFullPath(Path.Combine(root, componentsPath)) + Path.DirectorySeparatorChar; + + return Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories) + .Where(p => p.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) || p.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + .Where(p => !Path.GetRelativePath(root, p).Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(part => skip.Contains(part, StringComparer.OrdinalIgnoreCase))) + .Where(p => !Path.GetFullPath(p).StartsWith(componentsDir, StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p, StringComparer.Ordinal); + } + + // Applies rewrite to every opening tag, scanning to the closing > outside quotes. + internal static string RewriteTags(string text, string name, Func rewrite) + { + var result = new StringBuilder(); + var position = 0; + foreach (Match match in Regex.Matches(text, $@"<{name}(?=[\s/>])")) + { + if (match.Index < position) continue; + var end = TagEnd(text, match.Index + match.Length); + result.Append(text, position, match.Index - position); + result.Append(rewrite(text[match.Index..end])); + position = end; + } + result.Append(text, position, text.Length - position); + return result.ToString(); + } + + private static int TagEnd(string text, int start) + { + char? quote = null; + for (var i = start; i < text.Length; i++) + { + var c = text[i]; + if (quote != null) { if (c == quote) quote = null; } + else if (c is '"' or '\'') quote = c; + else if (c == '>') return i + 1; + } + return text.Length; + } + + // Renames Old, OldChanged, @bind-Old and @bind-Old:after (and so on) to the new name. + private static string RenameParameter(string tag, string oldName, string newName) => + Regex.Replace(tag, $@"(?<=\s)(@bind-)?{oldName}(Changed)?(?=[\s:=])", m => m.Groups[1].Value + newName + m.Groups[2].Value); + + // Calendar.SelectedDateChanged became EventCallback in 0.4.0-alpha.1; a handler taking DateTime no longer compiles. + private static IEnumerable CalendarHandlerNotes(string path, string text) + { + var handlers = new HashSet(); + RewriteTags(text, "Calendar", tag => + { + foreach (Match m in Regex.Matches(tag, @"(?<=\s)SelectedDateChanged\s*=\s*""@?(\w+)""")) + handlers.Add(m.Groups[1].Value); + return tag; + }); + foreach (var handler in handlers) + { + foreach (Match m in Regex.Matches(text, $@"\b{handler}\s*\(\s*DateTime\s+\w+")) + { + var line = text[..m.Index].Count(c => c == '\n') + 1; + yield return new MigrationNote(path, line, $"{handler} handles Calendar.SelectedDateChanged with a DateTime; it now needs a DateTime? (0.4.0-alpha.1)."); + } + } + } + + // Changed lines only, prefixed - and +, with their line numbers. + private static IEnumerable Diff(string before, string after) + { + var a = before.Split('\n'); + var b = after.Split('\n'); + for (var i = 0; i < Math.Max(a.Length, b.Length); i++) + { + var left = i < a.Length ? a[i].TrimEnd('\r') : null; + var right = i < b.Length ? b[i].TrimEnd('\r') : null; + if (left == right) continue; + if (left != null) yield return $"-{i + 1,5}: {left.Trim()}"; + if (right != null) yield return $"+{i + 1,5}: {right.Trim()}"; + } + } +} From 8b073fb87b568bf3bb37d5fc58d1ee9c4c15bfee Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 23 Oct 2026 07:03:27 +0200 Subject: [PATCH 2/4] test: cover shellui migrate Each migration, tag scoping (Calendar left alone, > inside a quoted value), the bound-CurrentPage exception, skipping installed components and obj, the Calendar note and an up-to-date app. --- ShellUI.Tests/MigrateTests.cs | 126 ++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 ShellUI.Tests/MigrateTests.cs diff --git a/ShellUI.Tests/MigrateTests.cs b/ShellUI.Tests/MigrateTests.cs new file mode 100644 index 0000000..73fd534 --- /dev/null +++ b/ShellUI.Tests/MigrateTests.cs @@ -0,0 +1,126 @@ +using ShellUI.CLI.Services; + +namespace ShellUI.Tests; + +public class MigrateTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"migrate-{Guid.NewGuid():N}"); + + public MigrateTests() + { + Directory.CreateDirectory(Path.Combine(_root, "Components", "UI")); + Directory.CreateDirectory(Path.Combine(_root, "Components", "Pages")); + Directory.CreateDirectory(Path.Combine(_root, "obj")); + File.WriteAllText(Path.Combine(_root, "MyApp.csproj"), ""); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + } + + private string Page(string name, string content) + { + var path = Path.Combine(_root, "Components", "Pages", name); + File.WriteAllText(path, content); + return path; + } + + private MigrationEdit SingleEdit(params string[] components) => Assert.Single(MigrateService.Plan(_root, components).Edits); + + [Fact] + public void Input_DropsTheNestedVariantEnum() + { + Page("Login.razor", "\n@code { var v = Input.InputVariant.Default; }"); + + var edit = SingleEdit("input"); + + Assert.Equal("\n@code { var v = InputVariant.Default; }", edit.After); + } + + [Fact] + public void DatePicker_RenamesSelectedDateButLeavesCalendarAlone() + { + Page("Booking.razor", + "\n" + + " b\" />\n" + + ""); + + var edit = SingleEdit("date-picker"); + + Assert.Equal( + "\n" + + " b\" />\n" + + "", edit.After); + } + + [Fact] + public void TimePicker_RenamesSelectedTime() + { + Page("Alarm.razor", ""); + + Assert.Equal("", SingleEdit("time-picker").After); + } + + [Fact] + public void Pagination_RenamesOnPageChangeUnlessCurrentPageIsBound() + { + Page("List.razor", + "\n" + + ""); + + var edit = SingleEdit("pagination"); + + Assert.Equal( + "\n" + + "", edit.After); + } + + [Fact] + public void Plan_SkipsInstalledComponentsAndBuildOutput() + { + File.WriteAllText(Path.Combine(_root, "Components", "UI", "Input.razor"), "Input.InputVariant.Error"); + File.WriteAllText(Path.Combine(_root, "obj", "Generated.cs"), "Input.InputVariant.Error"); + var page = Page("Form.razor", "Input.InputVariant.Error"); + + var plan = MigrateService.Plan(_root); + + Assert.Equal(page, Assert.Single(plan.Edits).Path); + } + + [Fact] + public void Plan_WithNoComponentsRunsEveryMigrationAndListsWhatItApplied() + { + Page("All.razor", "\n"); + + var edit = SingleEdit(); + + Assert.Equal(2, edit.Applied.Count); + Assert.Equal("\n", edit.After); + } + + [Fact] + public void Calendar_FlagsHandlersThatTakeADateTime() + { + var page = Page("Plan.razor", + "\n@code {\n void OnDay(DateTime day) { }\n void Other(DateTime d) { }\n}"); + + var plan = MigrateService.Plan(_root); + + Assert.Empty(plan.Edits); + var note = Assert.Single(plan.Notes); + Assert.Equal(page, note.Path); + Assert.Equal(3, note.Line); + } + + [Fact] + public void Plan_IsEmptyForAnUpToDateApp() + { + Page("Fine.razor", "\n\n@code { void OnDay(DateTime? d) { } }"); + + var plan = MigrateService.Plan(_root); + + Assert.Empty(plan.Edits); + Assert.Empty(plan.Notes); + } +} From 09b34f64885bf78d044b77f351075d5e41aed9a4 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 23 Oct 2026 07:03:27 +0200 Subject: [PATCH 3/4] docs: document shellui migrate --- README.md | 2 +- docs/CLI_SYNTAX.md | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4315130..755dfbf 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ShellUI is pre-1.0, so APIs and generated output can still change between minor ## Capabilities -- The CLI commands are `init`, `add`, `list`, `remove`, `update` and `doctor`, plus `theme init`, `theme apply`, and `theme update`. +- The CLI commands are `init`, `add`, `list`, `remove`, `update`, `doctor` and `migrate`, plus `theme init`, `theme apply`, and `theme update`. - The component registry has **254 entries**: **98 direct install targets** and **156 hidden dependency entries**. `list` shows direct targets; `add` resolves hidden dependencies. - `0.4.0-alpha.1` adds `kbd`, `aspect-ratio`, `button-group`, `toggle-group`, `input-group`, `number-input`, `stat-card`, `timeline`, `tree-view`, `qr-code`, `image-viewer`, `chat`, `chat-message`, `chat-input`, and the `auth-01`, `auth-02` and `auth-03` blocks. - `0.3.0` added `typed-select`, `command-palette`, `data-picker`, `multi-select`, `tag-input`, `donut-chart`, `radar-chart`, and `radial-chart`. diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index 396d15f..ded3ded 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -22,6 +22,7 @@ shellui list [--installed|--available] shellui remove shellui update [name...] [--all] [--force] shellui doctor +shellui migrate [name...] [--dry-run] [--yes] shellui theme init [--force] [--style