From b6a788a4184d549b098a23e30d17c441ca4d3d96 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Mon, 19 Oct 2026 23:24:07 +0200 Subject: [PATCH 01/12] feat(cli): bit CLI, Boilerplate and create project page follow-ups #13974 --- .github/agents/test-platform.md | 14 +- .../agents/test-platform/test-platform.ps1 | 4 +- src/Cli/Bit.Cli/CliApp.cs | 3 +- src/Cli/Bit.Cli/Commands/DiffCommand.cs | 71 +++++ src/Cli/Bit.Cli/Commands/DoctorCommand.cs | 1 + src/Cli/Bit.Cli/Commands/NewCommand.cs | 3 + src/Cli/Bit.Cli/Commands/SetupCommand.cs | 15 +- src/Cli/Bit.Cli/Commands/SharedOptions.cs | 4 +- src/Cli/Bit.Cli/Projects/NewWorkflow.cs | 44 ++- src/Cli/Bit.Cli/Projects/ProjectContext.cs | 40 +++ src/Cli/Bit.Cli/Projects/ProjectRun.cs | 116 ++++++++ src/Cli/Bit.Cli/Projects/ProjectSteps.cs | 225 ++++++++++++++- src/Cli/Bit.Cli/Projects/TemplateDiff.cs | 265 ++++++++++++++++++ src/Cli/Bit.Cli/Templates/TemplateLabels.cs | 3 +- .../Bit.Cli/Templates/TemplateSelection.cs | 2 + src/Cli/Bit.Cli/Tools/Hardware.cs | 14 +- src/Cli/Bit.Cli/Tools/ToolCatalog.cs | 116 ++++++-- src/Cli/Bit.Cli/Tools/ToolInstaller.cs | 11 +- src/Cli/Bit.Cli/Tools/ToolModel.cs | 2 + src/Cli/README.md | 35 ++- src/Cli/Tests/Bit.Cli.Tests/CommandTests.cs | 2 +- src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs | 83 ++++++ src/Cli/Tests/Bit.Cli.Tests/HardwareTests.cs | 19 +- .../Infrastructure/FakeProcessRunner.cs | 9 +- .../Tests/Bit.Cli.Tests/ProjectStepTests.cs | 125 ++++++++- src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs | 54 +++- .../.agents/skills/upgrade/SKILL.md | 6 + .../.claude/skills/upgrade/SKILL.md | 6 + ...entity - Authentication & Authorization.md | 4 +- ...ogging, OpenTelemetry and Health Checks.md | 4 +- ... Automated Testing (Unitigration Tests).md | 2 +- .../.docs/18- Available Agent Skills.md | 20 ++ .../.docs/19- Project Miscellaneous Files.md | 2 +- .../Bit.Boilerplate/.docs/20- .NET Aspire.md | 12 +- .../.gemini/commands/upgrade.toml | 9 + .../.github/agents/upgrade.agent.md | 65 +++++ .../.template.config/ide.host.json | 4 + .../.template.config/template.json | 23 ++ .../Boilerplate/Bit.Boilerplate/AGENTS.md | 1 + .../Bit.Boilerplate/Boilerplate.sln | 1 + .../Bit.Boilerplate/Boilerplate.slnx | 1 + .../Boilerplate/Bit.Boilerplate/README.md | 8 +- .../Pages/Management/OperationsPage.razor.cs | 12 +- .../ExceptionDelegatingHandler.cs | 6 + .../HttpMessageHandlers/RequestOptionNames.cs | 1 + .../Features/HealthChecks/HealthCheckTests.cs | 7 +- .../Configurations/UserConfiguration.cs | 27 +- .../20261018233101_Initial.Designer.cs | 6 +- .../Data/Migrations/20261018233101_Initial.cs | 6 +- .../Migrations/AppDbContextModelSnapshot.cs | 6 +- ...DistributedApplicationBuilderExtensions.cs | 19 +- .../Extensions/IResourceBuilderExtensions.cs | 2 +- .../Infrastructure/Realms/dev-realm.json | 4 +- .../Services/ContainerRuntime.cs | 75 +++++ .../Services/ContainersKeeper.cs | 11 +- .../Boilerplate.Server.AppHost/Program.cs | 3 + .../Extensions/WebApplicationExtensions.cs | 3 +- .../Identity/Dtos/SignInRequestDto.cs | 2 +- .../Identity/Dtos/SignUpRequestDto.cs | 2 +- .../Features/AppHost/ContainerRuntimeTests.cs | 25 ++ .../AppHost/KeycloakRealmImportTests.cs | 2 +- .../ProductImageCacheInvalidationTests.cs | 2 +- .../Caching/ProductResponseCacheTests.cs | 2 +- .../Categories/CategoryDeleteGuardTests.cs | 2 +- .../Chatbot/AiChatPanelHistoryUITests.cs | 2 +- .../HealthCheckIntegrationTests.cs | 21 +- .../src/Tests/Features/Identity/TestData.cs | 2 +- .../UserGroupFeatureManagementUITests.cs | 2 +- .../Products/AddOrEditProductPageTests.cs | 2 +- .../Products/ProductImageLifecycleTests.cs | 2 +- .../DashboardBroadcastTenantScopeTests.cs | 2 +- .../Tenants/TenantInvitationUITests.cs | 2 +- .../CreateProjectCommandBar.razor.cs | 2 +- .../Templates/CreateProject/ProjectSetup.cs | 4 +- .../Templates02GettingStartedPage.razor | 17 +- .../Templates04CreateProjectPage.razor | 3 +- .../Templates04CreateProjectPage.razor.cs | 24 +- .../Templates04CreateProjectPage.razor.scss | 16 ++ .../Templates/Templates05RunProjectPage.razor | 4 +- .../Services/TelegramBotService.cs | 5 +- 80 files changed, 1605 insertions(+), 178 deletions(-) create mode 100644 src/Cli/Bit.Cli/Commands/DiffCommand.cs create mode 100644 src/Cli/Bit.Cli/Projects/ProjectRun.cs create mode 100644 src/Cli/Bit.Cli/Projects/TemplateDiff.cs create mode 100644 src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/.agents/skills/upgrade/SKILL.md create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/.claude/skills/upgrade/SKILL.md create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/.gemini/commands/upgrade.toml create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainerRuntime.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/ContainerRuntimeTests.cs diff --git a/.github/agents/test-platform.md b/.github/agents/test-platform.md index 9f65e2f2adb..b2665842688 100644 --- a/.github/agents/test-platform.md +++ b/.github/agents/test-platform.md @@ -146,7 +146,7 @@ websites - is checked. Every command prints what it checked and exits non-zero o What has cost time here before: - The template tree's tests start its Aspire app host first, with every container it has (SQL Server, PostgreSQL, - MySQL, Keycloak, both Redis, the S3 storage, MailPit), so they need Docker Desktop and are slow to start; that + MySQL, Keycloak, both Redis, the S3 storage, MailPit), so they need Docker and are slow to start; that start once hung for over 15 minutes. A generated project starts only its own few containers. - Install a packed template with `--debug:custom-hive ` on both `dotnet new install` and `dotnet new`, so the machine's own Bit.Boilerplate template stays as it is; `bit new` already uses a private hive in its home @@ -407,7 +407,7 @@ request lines in the server's log. Stop the site and the stand-in afterwards; a A new project's dev container is the .NET SDK image with Docker inside it. When it's created, it installs bit and runs `bit setup`, which does the rest the way it does on any machine: Node.js, the Aspire CLI, the build tools, the -certificate, the packages, the build and Chromium for the UI tests. `devcontainer` checks that on Docker Desktop's Linux +certificate, the packages, the build and Chromium for the UI tests. `devcontainer` checks that on a Linux Docker engine, as VS Code and Codespaces would run it: `bit new` makes a project here without setting it up, the devcontainers CLI brings its container up, and `bit doctor` runs inside it. @@ -417,18 +417,18 @@ devcontainers CLI brings its container up, and `bit doctor` runs inside it. - `-PrereleasePackages` puts the packages of the newest Prerelease nuget packages run into that feed too, bit's aside, and maps every `Bit.*` to it: a template that already uses library changes nuget.org's build of its version doesn't have compiles against them. -- It's heavy: a few GB in Docker Desktop's VM and about ten minutes, most of them for the container's image and the +- It's heavy: a few GB in Docker's VM and about ten minutes, most of them for the container's image and the restore, then about half an hour for the Aspire check below. Run it on its own, after the E2E suite, never next to it. - Done means every `bit setup` step passed, `bit doctor` exits with 0, with VS Code not needed inside a container, - and the Aspire check below passes. `bit doctor`'s memory warning describes Docker Desktop's VM, not this machine, + and the Aspire check below passes. `bit doctor`'s memory warning describes Docker's VM, not this machine, and is expected. - The logs of `bit new`, of the container's start and of `bit doctor` stay in the folder the helper names. `-Keep` leaves the container up, to look inside it with the devcontainers CLI; bit's own logs are in its home folder there. A container that sets itself up can still fail to run the app. And the Aspire containers have to behave in there as step 6 shows they do on Windows: on Linux the keeper that stops them is a different process, started in a session of -its own, and the containers run on the dev container's own Docker engine, not on Docker Desktop's. So run +its own, and the containers run on the dev container's own Docker engine, not on the host's. So run `devcontainer` with `-Keep`, and once `bit doctor` has passed, do step 6's check inside the container: three `dotnet run`s of the app host, each until the web app answers inside the container and each ended the way closing its terminal does, with the same bar for the second run's reuse, the idle stop and the third run's volumes. Then @@ -456,9 +456,9 @@ alive for its idle time. `aspire` checks that the way a developer meets it: the Bit packages come from nuget.org at the version the template asks for. The project's `global.json` asks for the repository's SDK, which needs the workloads the template's clients build with (`wasm-tools`): an SDK that Visual Studio installed brings only its own. -- It takes about half an hour, most of it the idle wait, and a few GB of Docker Desktop's memory while the app runs. +- It takes about half an hour, most of it the idle wait, and a few GB of Docker's memory while the app runs. Start it in the background once the CD's deploy jobs are done, and never next to `devcontainer` or the Android - stage. Its containers hold that memory, and Docker Desktop's VM keeps it for a while after they stop: IIS sites + stage. Its containers hold that memory, and Docker's VM keeps it for a while after they stop: IIS sites deployed next to them took minutes to start, and one never did ("Managed server didn't initialize after 120000 ms" in the Application log), which a CD reports as a failed health check. diff --git a/.github/agents/test-platform/test-platform.ps1 b/.github/agents/test-platform/test-platform.ps1 index 6627a02cf00..24b8db8b4da 100644 --- a/.github/agents/test-platform/test-platform.ps1 +++ b/.github/agents/test-platform/test-platform.ps1 @@ -16,7 +16,7 @@ e2e Runs Boilerplate.Tests.E2E stage by stage, as RunTests.bat does, keeping each stage's log and TRX. mcp One call to https://bitplatform.dev/mcp: tools/list, or tools/call when -Tool is given. devcontainer - Creates a project with bit new, brings bit Boilerplate's dev container up on Docker Desktop's Linux engine + Creates a project with bit new, brings bit Boilerplate's dev container up on a Linux Docker engine with the devcontainers CLI, and runs bit doctor in it. With -Sha, bit and the template come from that commit; without it, from nuget.org, as users get them. Removes the container, its volumes and its image afterwards unless -Keep is given, and keeps the logs. Exits 1 when a setup step fails or bit doctor @@ -868,7 +868,7 @@ function Write-BitSteps([string] $log, [string] $after) { function Assert-DockerLinux { if ((docker version --format '{{.Server.Os}}' 2>$null) -ne 'linux') { - throw 'Docker Desktop must be running on its Linux engine.' + throw 'Docker must be running with Linux containers.' } } diff --git a/src/Cli/Bit.Cli/CliApp.cs b/src/Cli/Bit.Cli/CliApp.cs index 26ce85aa8b9..f3d607a56c0 100644 --- a/src/Cli/Bit.Cli/CliApp.cs +++ b/src/Cli/Bit.Cli/CliApp.cs @@ -14,7 +14,7 @@ public static class CliApp public const int ExitUsage = 2; public const int ExitCheckFailed = 3; - private static readonly string[] longRunningCommands = ["new", "setup", "translate", "doctor", "clean"]; + private static readonly string[] longRunningCommands = ["new", "setup", "diff", "translate", "doctor", "clean"]; public static RootCommand BuildRootCommand(Func services) { @@ -22,6 +22,7 @@ public static RootCommand BuildRootCommand(Func services) { NewCommand.Create(services), SetupCommand.Create(services), + DiffCommand.Create(services), DoctorCommand.Create(services), TrustCommand.Create(services), CleanCommand.Create(services), diff --git a/src/Cli/Bit.Cli/Commands/DiffCommand.cs b/src/Cli/Bit.Cli/Commands/DiffCommand.cs new file mode 100644 index 00000000000..ef3ba037d20 --- /dev/null +++ b/src/Cli/Bit.Cli/Commands/DiffCommand.cs @@ -0,0 +1,71 @@ +using System.CommandLine; +using Bit.Cli.Infrastructure; +using Bit.Cli.Projects; +using Bit.Cli.Templates; + +namespace Bit.Cli.Commands; + +public static class DiffCommand +{ + public static Command Create(Func services) + { + var fromArgument = new Argument("from") { Description = "The bit Boilerplate version the project comes from. Default: the one its README.md names.", Arity = ArgumentArity.ZeroOrOne }; + var toArgument = new Argument("to") { Description = $"The version to compare it with. Default: this bit's own, {BuildInfo.Version}.", Arity = ArgumentArity.ZeroOrOne }; + var projectOption = new Option("--project") { Description = "The project whose README.md gives the name, version, options and seeded accounts' password. Default: the current folder." }; + var nameOption = new Option("--name") { Description = "The name to create both versions with. Default: the project's own." }; + var outputOption = new Option("--output", "-o") { Description = "The folder of the git repository both versions are committed to. Default: ~/.bitplatform/diffs/--." }; + var templatePackageOption = new Option("--template-package") { Description = "Compare with a local Bit.Boilerplate .nupkg, or the template's folder in a bitplatform checkout." }; + + var command = new Command("diff", "Show what changed in bit Boilerplate between two versions: creates the project with each into one git repository, so the second commit is the upgrade.") + { + fromArgument, + toArgument, + projectOption, + nameOption, + outputOption, + templatePackageOption + }; + + var templateOptions = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var parameter in TemplateManifest.Embedded.Parameters) + { + var option = new Option($"--{parameter.Name}") + { + Arity = ArgumentArity.ZeroOrOne, + Description = $"{TemplateLabels.For(parameter)}. Default: what the project's README.md records." + }; + + templateOptions[parameter.Name] = option; + command.Options.Add(option); + } + + command.SetAction(async (parseResult, cancellationToken) => + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (name, option) in templateOptions) + { + if (parseResult.GetResult(option) is { } result) + { + values[name] = result.Tokens.Count == 0 ? "true" : result.Tokens[0].Value; + } + } + + var request = new DiffRequest + { + From = parseResult.GetValue(fromArgument), + To = parseResult.GetValue(toArgument), + Project = parseResult.GetValue(projectOption), + Name = parseResult.GetValue(nameOption), + Output = parseResult.GetValue(outputOption), + TemplatePackage = parseResult.GetValue(templatePackageOption), + TemplateValues = values + }; + + return await new TemplateDiff(services()).RunAsync(request, cancellationToken); + }); + + return command; + } +} diff --git a/src/Cli/Bit.Cli/Commands/DoctorCommand.cs b/src/Cli/Bit.Cli/Commands/DoctorCommand.cs index 710b2a630a1..f0faf6e42c9 100644 --- a/src/Cli/Bit.Cli/Commands/DoctorCommand.cs +++ b/src/Cli/Bit.Cli/Commands/DoctorCommand.cs @@ -45,6 +45,7 @@ public static Command Create(Func services) var needs = new ToolNeeds { Aspire = aspire, + ContainersRequired = aspire && (projectName is null || ProjectContext.AppHostNeedsContainers(Path.Combine(cli.Environment.CurrentDirectory, "src", "Server", $"{projectName}.Server.AppHost"))), NativeWebAssembly = projectName is not null && ProjectContext.UsesNativeWebAssembly(cli.Environment.CurrentDirectory), Platforms = platforms, Ide = IdeLocator.FindAll(cli.Environment, cli.Runner).FirstOrDefault()?.Id, diff --git a/src/Cli/Bit.Cli/Commands/NewCommand.cs b/src/Cli/Bit.Cli/Commands/NewCommand.cs index 088b38a7bf1..be396b18012 100644 --- a/src/Cli/Bit.Cli/Commands/NewCommand.cs +++ b/src/Cli/Bit.Cli/Commands/NewCommand.cs @@ -58,6 +58,8 @@ public sealed record NewRequest public bool NoBrowsers { get; init; } + public bool NoRun { get; init; } + public IReadOnlyList Properties { get; init; } = []; public string? PropertyError { get; init; } @@ -179,6 +181,7 @@ public static Command Create(Func services) GitHubRepo = parseResult.GetValue(gitHubRepo), NoUpdate = parseResult.GetValue(noUpdate), NoBrowsers = skipSetup || parseResult.GetValue(shared.NoBrowsers), + NoRun = skipSetup || parseResult.GetValue(shared.NoRun), Properties = properties, PropertyError = propertyError, Arguments = [.. parseResult.Tokens.Select(t => t.Value)], diff --git a/src/Cli/Bit.Cli/Commands/SetupCommand.cs b/src/Cli/Bit.Cli/Commands/SetupCommand.cs index 88263dc5bfe..b2e25f4a786 100644 --- a/src/Cli/Bit.Cli/Commands/SetupCommand.cs +++ b/src/Cli/Bit.Cli/Commands/SetupCommand.cs @@ -58,9 +58,9 @@ public static Command Create(Func services) } var context = new ProjectContext { Name = name, Directory = directory, Platforms = platforms, BuildProperties = properties }; - var aspire = Directory.Exists(Path.Combine(directory, "src", "Server", $"{name}.Server.AppHost")); + var aspire = Directory.Exists(context.AppHostDirectory); var requirements = TemplateRequirements.FromProject(directory, name); - var needs = new ToolNeeds { Aspire = aspire, NativeWebAssembly = ProjectContext.UsesNativeWebAssembly(directory), Platforms = platforms, Ide = IdeLocator.None, Sdk = requirements.Sdk, NodeMajor = requirements.NodeMajor, AspireVersion = requirements.Aspire }; + var needs = new ToolNeeds { Aspire = aspire, ContainersRequired = aspire && context.NeedsContainers, NativeWebAssembly = ProjectContext.UsesNativeWebAssembly(directory), Platforms = platforms, Ide = IdeLocator.None, Sdk = requirements.Sdk, NodeMajor = requirements.NodeMajor, AspireVersion = requirements.Aspire }; var hardware = NewWorkflow.ProbeHardwareAsync(cli, directory, cancellationToken); var tools = parseResult.GetValue(shared.NoTools) ? [] @@ -90,9 +90,11 @@ public static Command Create(Func services) await NewWorkflow.RunProjectStepAsync(steps, context, "vscode-extensions", "Installing VS Code extensions", projectSteps.VsCodeExtensionsAsync, cancellationToken); } - if (sdkReady && NewWorkflow.StartsAspireOnce(cli, aspire, parseResult.GetValue(shared.NoBuild))) + var runMode = ProjectRun.Mode(cli, aspire, parseResult.GetValue(shared.NoBuild), parseResult.GetValue(shared.NoRun)); + + if (sdkReady && runMode is not ProjectRunMode.None) { - await NewWorkflow.RunProjectStepAsync(steps, context, "aspire-start", "Starting the project once with Aspire", projectSteps.AspireStartAsync, cancellationToken); + await ProjectRun.RunProjectAsync(cli, steps, context, projectSteps, runMode, aspire, cancellationToken); } cli.Console.Out.WriteLine(); @@ -105,6 +107,11 @@ public static Command Create(Func services) cli.Console.Out.MarkupLine($" {Markup.Escape(followUp.FollowUp!)}"); } + if (projectSteps.Running is { } running) + { + await ProjectRun.KeepRunningAsync(cli, running, cancellationToken); + } + return steps.AnyFailed ? CliApp.ExitFailed : CliApp.ExitOk; }); diff --git a/src/Cli/Bit.Cli/Commands/SharedOptions.cs b/src/Cli/Bit.Cli/Commands/SharedOptions.cs index 11e35a33ea1..c9c1add1b06 100644 --- a/src/Cli/Bit.Cli/Commands/SharedOptions.cs +++ b/src/Cli/Bit.Cli/Commands/SharedOptions.cs @@ -33,6 +33,8 @@ public sealed class SharedOptions public Option NoBrowsers { get; } = new("--no-browsers") { Description = "Don't install Playwright's browsers for the UI tests." }; + public Option NoRun { get; } = new("--no-run") { Description = "Don't start the project at the end, or open it in the browser." }; + public Option Properties { get; } = new("--property", "-p") { Description = "An MSBuild property for the restore and the build, e.g. -p:EnforceCodeStyleInBuild=true. Repeat it for more.", @@ -41,7 +43,7 @@ public sealed class SharedOptions public void AddTo(Command command) { - foreach (var option in new Option[] { Platforms, Tools, Yes, NonInteractive, NoTools, NoWorkloads, NoRestore, NoBuild, NoBrowsers, Properties }) + foreach (var option in new Option[] { Platforms, Tools, Yes, NonInteractive, NoTools, NoWorkloads, NoRestore, NoBuild, NoBrowsers, NoRun, Properties }) { command.Options.Add(option); } diff --git a/src/Cli/Bit.Cli/Projects/NewWorkflow.cs b/src/Cli/Bit.Cli/Projects/NewWorkflow.cs index 0e4b715559e..52e53ba1129 100644 --- a/src/Cli/Bit.Cli/Projects/NewWorkflow.cs +++ b/src/Cli/Bit.Cli/Projects/NewWorkflow.cs @@ -65,7 +65,7 @@ public async Task RunAsync(NewRequest request, CancellationToken cancellati var ide = request.NoOpen ? IdeLocator.None : request.Ide; var requirements = request.TemplatePackage is null ? TemplateRequirements.Embedded : TemplateRequirements.FromPackage(Path.GetFullPath(request.TemplatePackage, cli.Environment.CurrentDirectory)); - var needs = new ToolNeeds { Aspire = selection.Aspire, Containers = selection.AspireContainers(), NativeWebAssembly = selection.IsTrue("offlineDb"), GitHubRepo = request.GitHubRepo, Platforms = platforms, Ide = ide, Sdk = requirements.Sdk, NodeMajor = requirements.NodeMajor, AspireVersion = requirements.Aspire }; + var needs = new ToolNeeds { Aspire = selection.Aspire, Containers = selection.AspireContainers(), ContainersRequired = selection.NeedsContainers, NativeWebAssembly = selection.IsTrue("offlineDb"), GitHubRepo = request.GitHubRepo, Platforms = platforms, Ide = ide, Sdk = requirements.Sdk, NodeMajor = requirements.NodeMajor, AspireVersion = requirements.Aspire }; var hardware = request.NoSetup ? null : ProbeHardwareAsync(cli, directory, cancellationToken); var selectedTools = request.NoTools ? [] : await ChooseToolsAsync(cli, needs, request.Tools, request.ToolsGiven, interactive, hardware, cancellationToken); @@ -127,7 +127,7 @@ public async Task RunAsync(NewRequest request, CancellationToken cancellati if (sdkReady is false) { - steps.Add("format", StepResult.Skipped("Skipped dotnet format, the migration and Aspire's first start", "they need the project's .NET SDK")); + steps.Add("format", StepResult.Skipped("Skipped dotnet format, the migration and the first start", "they need the project's .NET SDK")); } if (sdkReady && request.NoFormat is false) @@ -160,9 +160,11 @@ public async Task RunAsync(NewRequest request, CancellationToken cancellati await RunProjectStepAsync(steps, context, "vscode-extensions", "Installing VS Code extensions", projectSteps.VsCodeExtensionsAsync, cancellationToken); } - if (sdkReady && StartsAspireOnce(cli, selection.Aspire, request.NoBuild)) + var runMode = ProjectRun.Mode(cli, selection.Aspire, request.NoBuild, request.NoRun); + + if (sdkReady && runMode is not ProjectRunMode.None) { - await RunProjectStepAsync(steps, context, "aspire-start", "Starting the project once with Aspire", projectSteps.AspireStartAsync, cancellationToken); + await ProjectRun.RunProjectAsync(cli, steps, context, projectSteps, runMode, selection.Aspire, cancellationToken); } if (ide is not IdeLocator.None) @@ -171,7 +173,14 @@ public async Task RunAsync(NewRequest request, CancellationToken cancellati } WriteSummary(cli, steps, context, selection, stopwatch.Elapsed, request.NoSetup); - return steps.AnyFailed ? CliApp.ExitFailed : CliApp.ExitOk; + var exitCode = steps.AnyFailed ? CliApp.ExitFailed : CliApp.ExitOk; + + if (projectSteps.Running is { } running) + { + await ProjectRun.KeepRunningAsync(cli, running, cancellationToken); + } + + return exitCode; } public static async Task RunSetupStepsAsync(CliServices cli, StepRunner steps, ProjectContext context, ProjectSteps projectSteps, bool noWorkloads, bool noRestore, bool noBuild, bool noBrowsers, CancellationToken cancellationToken) @@ -217,8 +226,6 @@ public static async Task RunSetupStepsAsync(CliServices cli, StepRunner st return true; } - public static bool StartsAspireOnce(CliServices cli, bool aspire, bool noBuild) => aspire && noBuild is false && cli.Environment.IsCI is false; - public static async Task RunProjectStepAsync(StepRunner steps, ProjectContext context, string id, string title, Func, CancellationToken, Task> work, CancellationToken cancellationToken) { if (context.Exists is false) @@ -403,6 +410,7 @@ await steps.RunAsync("create", $"Creating {context.Name}", async (progress, ct) { var manifest = TemplateSource.ReadManifest(package.PackagePath) ?? selection.Manifest; var arguments = selection.ToTemplateArguments().Concat(request.ExtraTemplateArguments).ToList(); + context.SeedPassword = SeedPassword(manifest, selection, arguments); var result = await source.CreateAsync(package, manifest, context.Name, context.Directory, arguments, progress, ct); if (result.Succeeded && context.Exists is false) @@ -420,6 +428,24 @@ await steps.RunAsync("create", $"Creating {context.Name}", async (progress, ct) }, cancellationToken); } + public static string? SeedPassword(TemplateManifest manifest, TemplateSelection selection, List arguments) + { + if (manifest.Find("seedPassword") is null) + return null; + + var given = arguments.FindIndex(a => string.Equals(a, "--seedPassword", StringComparison.OrdinalIgnoreCase)); + + if (given >= 0 && given + 1 < arguments.Count) + return arguments[given + 1]; + + if (selection.HasExplicitValue("seedPassword")) + return selection["seedPassword"]; + + var password = $"{System.Security.Cryptography.RandomNumberGenerator.GetInt32(10_000_000, 100_000_000)}"; + arguments.AddRange(["--seedPassword", password]); + return password; + } + private void TagTelemetry(TemplateSelection selection, IReadOnlySet platforms, IReadOnlyList tools, string ide, NewRequest request) { foreach (var parameter in selection.Manifest.Parameters.Where(p => p.Type is not TemplateParameterType.String)) @@ -466,7 +492,7 @@ private void WritePlan(ProjectContext context, TemplateSelection selection, IRea if (request.GitHubRepo) stepsList.Add("private GitHub repository"); if (request.NoTrust is false) stepsList.Add("trust for VS Code and AI tools"); if (ide is IdeLocator.VsCode) stepsList.Add("VS Code extensions"); - if (StartsAspireOnce(cli, selection.Aspire, request.NoBuild)) stepsList.Add("a first Aspire start"); + if (ProjectRun.Mode(cli, selection.Aspire, request.NoBuild, request.NoRun) is not ProjectRunMode.None and var runMode) stepsList.Add(ProjectRun.PlanText(runMode, selection.Aspire)); if (ide is not IdeLocator.None) stepsList.Add($"open in {IdeLocator.Title(ide)}"); Row("Then", string.Join(", ", stepsList)); @@ -519,7 +545,7 @@ private static void WriteSummary(CliServices cli, StepRunner steps, ProjectConte } cli.Console.Out.MarkupLine($" [bold]Run it[/] cd {Markup.Escape(ProcessSpec.Quote(relative))} [grey]then[/] {Markup.Escape(run)}"); - cli.Console.Out.MarkupLine(" [bold]Sign in[/] test@bitplatform.dev / 123456 [grey]seeded accounts, remove them before you deploy[/]"); + cli.Console.Out.MarkupLine($" [bold]Sign in[/] test@bitplatform.dev / {Markup.Escape(context.SeedPassword ?? "123456")} [grey]seeded accounts, remove them before you deploy[/]"); cli.Console.Out.MarkupLine(" [bold]Docs[/] https://bitplatform.dev/templates"); } diff --git a/src/Cli/Bit.Cli/Projects/ProjectContext.cs b/src/Cli/Bit.Cli/Projects/ProjectContext.cs index a24cdf06621..00094c94ea9 100644 --- a/src/Cli/Bit.Cli/Projects/ProjectContext.cs +++ b/src/Cli/Bit.Cli/Projects/ProjectContext.cs @@ -34,6 +34,14 @@ public sealed partial class ProjectContext public string? Database => Template?.Database ?? ReadDatabaseFromProject(); + public bool NeedsContainers => Template?.NeedsContainers ?? AppHostNeedsContainers(AppHostDirectory); + + public bool IsApiStandalone => Template is not null + ? string.Equals(Template["api"], "Standalone", StringComparison.OrdinalIgnoreCase) + : System.IO.Directory.Exists(ServerApiDirectory) && ServerWebReferencesServerApi() is false; + + public string? SeedPassword { get; set; } + public string TargetFrameworkVersion { get @@ -106,6 +114,35 @@ public string TargetFrameworkVersion } } + private bool ServerWebReferencesServerApi() + { + var web = Path.Combine(Directory, "src", "Server", $"{Name}.Server.Web", $"{Name}.Server.Web.csproj"); + + try + { + return File.Exists(web) is false || File.ReadAllText(web).Contains($"{Name}.Server.Api.csproj", StringComparison.OrdinalIgnoreCase); + } + catch (Exception exp) when (exp is IOException or UnauthorizedAccessException) + { + return true; + } + } + + public static bool AppHostNeedsContainers(string appHostDirectory) + { + try + { + return System.IO.Directory.Exists(appHostDirectory) + && System.IO.Directory.EnumerateFiles(appHostDirectory, "*.cs", SearchOption.AllDirectories) + .Where(file => file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) is false && file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) is false) + .Any(file => ContainerServerRegex().IsMatch(File.ReadAllText(file))); + } + catch (Exception exp) when (exp is IOException or UnauthorizedAccessException) + { + return true; + } + } + public static bool UsesNativeWebAssembly(string directory) { try @@ -124,4 +161,7 @@ public static bool UsesNativeWebAssembly(string directory) [GeneratedRegex(@"]*>\s*true\s*", RegexOptions.IgnoreCase)] private static partial Regex NativeWebAssemblyRegex(); + + [GeneratedRegex(@"\.Add(Azure)?(SqlServer|Postgres\w*|PostgreSQL|MySql|ManagedRedis|Redis\w*)\(")] + private static partial Regex ContainerServerRegex(); } diff --git a/src/Cli/Bit.Cli/Projects/ProjectRun.cs b/src/Cli/Bit.Cli/Projects/ProjectRun.cs new file mode 100644 index 00000000000..0b97cb2e606 --- /dev/null +++ b/src/Cli/Bit.Cli/Projects/ProjectRun.cs @@ -0,0 +1,116 @@ +using System.Runtime.InteropServices; +using Bit.Cli.Infrastructure; +using Spectre.Console; + +namespace Bit.Cli.Projects; + +public enum ProjectRunMode +{ + None, + Once, + UntilClosed +} + +public static class ProjectRun +{ + public static ProjectRunMode Mode(CliServices cli, bool aspire, bool noBuild, bool noRun) + { + if (noBuild || noRun || cli.Environment.IsCI) + return ProjectRunMode.None; + + if (cli.HasTerminal && cli.Environment.CodingAgent is null && cli.Environment.IsDevContainer is false) + return ProjectRunMode.UntilClosed; + + return aspire ? ProjectRunMode.Once : ProjectRunMode.None; + } + + public static string Title(ProjectRunMode mode, bool aspire) => mode is ProjectRunMode.Once + ? "Starting the project once with Aspire" + : aspire ? "Starting the project with Aspire" : "Starting the project"; + + public static string PlanText(ProjectRunMode mode, bool aspire) => mode switch + { + ProjectRunMode.Once => "a first Aspire start", + _ => aspire ? "start it with Aspire and open the dashboard and the app" : "start it and open it in the browser" + }; + + public static async Task RunProjectAsync(CliServices cli, StepRunner steps, ProjectContext context, ProjectSteps projectSteps, ProjectRunMode mode, bool aspire, CancellationToken cancellationToken) + { + var title = Title(mode, aspire); + + if (steps.Reports.Any(r => r.Id is "build" && r.Result.Status is StepStatus.Failed)) + { + steps.Add("run", StepResult.Skipped("Didn't start the project", "the build failed")); + return; + } + + await NewWorkflow.RunProjectStepAsync(steps, context, "run", title, aspire + ? (progress, ct) => projectSteps.AspireStartAsync(mode is ProjectRunMode.UntilClosed, progress, ct) + : projectSteps.DotnetRunAsync, cancellationToken); + } + + public static async Task KeepRunningAsync(CliServices cli, RunningProject running, CancellationToken cancellationToken) + { + var urls = new[] { running.DashboardUrl, running.AppUrl }.OfType().ToList(); + + foreach (var url in urls) + { + OpenInBrowser(cli, url); + } + + cli.Console.Out.WriteLine(); + + for (var i = 0; i < urls.Count; i++) + { + var label = urls[i] == running.DashboardUrl ? " [grey]Aspire dashboard[/]" : " [grey]The app[/]"; + cli.Console.Out.MarkupLine($"{(i == 0 ? " [bold]Running[/] " : " ")}{Markup.Escape(urls[i])}{label}"); + } + + cli.Console.Out.MarkupLine(" [grey]It keeps running while this terminal is open. Close it, or press Ctrl+C, to stop the project.[/]"); + cli.Console.Out.WriteLine(); + + using var closed = new CancellationTokenSource(); + var terminalClosed = false; + + using var hangUp = PosixSignalRegistration.Create(PosixSignal.SIGHUP, signal => + { + signal.Cancel = true; + terminalClosed = true; + running.StopAsync(cli.Environment.IsWindows).Wait(TimeSpan.FromSeconds(cli.Environment.IsWindows ? 4 : 60)); + closed.Cancel(); + }); + + using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, closed.Token); + + var waiting = Task.Delay(Timeout.Infinite, stop.Token); + + if (await Task.WhenAny(waiting, running.Exited ?? waiting) != waiting && stop.IsCancellationRequested is false) + { + cli.Console.Out.MarkupLine(" [yellow]The project stopped.[/]"); + await running.StopAsync(false); + return; + } + + if (terminalClosed) + return; + + cli.Console.Out.MarkupLine(" [grey]Stopping the project[/]"); + await running.StopAsync(true); + } + + public static void OpenInBrowser(CliServices cli, string url) + { + ProcessSpec? open = cli.Environment.Os switch + { + HostOs.Windows => new ProcessSpec { FileName = "rundll32.exe", Arguments = ["url.dll,FileProtocolHandler", url] }, + HostOs.MacOS => new ProcessSpec { FileName = "open", Arguments = [url] }, + _ when cli.Runner.FindExecutable("xdg-open") is not null => new ProcessSpec { FileName = "xdg-open", Arguments = [url] }, + _ => null + }; + + if (open is not null) + { + cli.Runner.StartDetached(open); + } + } +} diff --git a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs index a1ca50cfb8d..680add32440 100644 --- a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs +++ b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs @@ -12,6 +12,17 @@ namespace Bit.Cli.Projects; public sealed record PlaywrightDriver(string Node, string Cli, string? Version); +public sealed class RunningProject +{ + public string? DashboardUrl { get; init; } + + public string? AppUrl { get; init; } + + public required Func StopAsync { get; init; } + + public Task? Exited { get; init; } +} + public sealed partial class ProjectSteps(CliServices cli, ProjectContext project) { public const string DevelopmentCertificateSubject = "CN=AppCertificate, OU=Development"; @@ -283,9 +294,17 @@ public async Task MigrationAsync(Action progress, Cancellati return StepResult.Succeeded("Added the Initial migration", $"{project.Database}{(committed is null ? "" : ", committed")}"); } - public async Task AspireStartAsync(Action progress, CancellationToken cancellationToken) + public RunningProject? Running { get; private set; } + + public Func> AliveProbe { get; init; } = IsAliveAsync; + + private string ServerWebDirectory => Path.Combine(project.Directory, "src", "Server", $"{project.Name}.Server.Web"); + + public Task AspireStartAsync(Action progress, CancellationToken cancellationToken) => AspireStartAsync(keepRunning: false, progress, cancellationToken); + + public async Task AspireStartAsync(bool keepRunning, Action progress, CancellationToken cancellationToken) { - const string skipped = "Skipped Aspire's first start"; + var skipped = keepRunning ? "Didn't start the project" : "Skipped Aspire's first start"; const string failed = "Couldn't start the project with Aspire"; var followUp = $"cd {ProcessSpec.Quote(project.Directory)} && aspire start"; @@ -295,32 +314,203 @@ public async Task AspireStartAsync(Action progress, Cancella if (Runner.FindExecutable("aspire") is null) return StepResult.Warning(skipped, "the Aspire CLI isn't installed", followUp); - if (await ContainerRuntimeProblemAsync(cancellationToken) is { } problem) + var containerProblem = await ContainerRuntimeProblemAsync(cancellationToken); + + if (containerProblem is { } problem && project.NeedsContainers) return StepResult.Warning(skipped, problem.Detail, followUp, problem.Fix); + if (containerProblem is { } missing && keepRunning is false) + return StepResult.Skipped(skipped, $"there are no containers to get ready: {missing.Detail}"); + var startTimeout = cli.Environment.GetVariable("ASPIRE_CLI_START_TIMEOUT") is null ? new Dictionary { ["ASPIRE_CLI_START_TIMEOUT"] = "1200" } : null; - var start = await Aspire(["start", "--apphost", project.AppHostDirectory, "--non-interactive"], progress, TimeSpan.FromMinutes(30), cancellationToken, startTimeout); + var start = await Aspire(["start", "--apphost", project.AppHostDirectory, "--non-interactive", "--format", "Json"], progress, TimeSpan.FromMinutes(30), cancellationToken, startTimeout); if (start.Succeeded is false) return StepResult.FromProcess(start, "", failed, followUp) with { Status = StepStatus.Warning }; + var stop = AspireSpec(["stop", "--apphost", project.AppHostDirectory, "--non-interactive"], _ => { }, TimeSpan.FromMinutes(5)); + var keep = false; + try { foreach (var resource in AspireResourcesToWaitFor(project.AppHostDirectory)) { - if (await WaitUntilHealthyAsync(resource, progress, cancellationToken) is { } reason) + if (await WaitUntilHealthyAsync(resource, containerProblem is null, progress, cancellationToken) is { } reason) return StepResult.Warning($"Aspire started, {resource} didn't get healthy", reason, followUp); } + + if (keepRunning is false) + return StepResult.Succeeded("Started and stopped the project with Aspire", "its images and builds are ready, so the IDE starts it fast"); + + Running = new RunningProject + { + DashboardUrl = DashboardUrlRegex().Match(start.Output) is { Success: true } dashboard ? dashboard.Groups["url"].Value : null, + AppUrl = await AspireResourceUrlAsync("serverweb", cancellationToken) ?? ReadLaunchUrl(ServerWebDirectory), + StopAsync = async detached => + { + if (detached is false || Runner.StartDetached(stop) is false) + { + await Runner.RunAsync(stop, CancellationToken.None); + } + } + }; + + keep = true; + + return containerProblem is { } absent + ? StepResult.Succeeded("Started the project with Aspire", "without its containers", $"{absent.Detail}, so Keycloak, Mailpit and the other containers aren't running.{(absent.Fix is null ? "" : $" {absent.Fix}")}") + : StepResult.Succeeded("Started the project with Aspire"); } finally { - await Aspire(["stop", "--apphost", project.AppHostDirectory, "--non-interactive"], progress, TimeSpan.FromMinutes(5), CancellationToken.None); + if (keep is false) + { + await Runner.RunAsync(stop with { OnOutputLine = progress }, CancellationToken.None); + } } + } + + public async Task DotnetRunAsync(Action progress, CancellationToken cancellationToken) + { + const string skipped = "Didn't start the project"; + string[] servers = project.IsApiStandalone ? [project.ServerApiDirectory, ServerWebDirectory] : [ServerWebDirectory]; + var followUp = string.Join(" & ", servers.Select(server => $"dotnet run --project {ProcessSpec.Quote(Path.GetRelativePath(project.Directory, server))}")); - return StepResult.Succeeded("Started and stopped the project with Aspire", "its images and builds are ready, so the IDE starts it fast"); + if (servers.FirstOrDefault(server => System.IO.Directory.Exists(server) is false || ReadLaunchUrl(server) is null) is { } missing) + return StepResult.Skipped(skipped, $"{Path.GetFileName(missing)} has no URL in its launchSettings.json"); + + var running = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var runs = new List>(); + + async Task StopAsync() + { + await running.CancelAsync(); + await Task.WhenAny(Task.WhenAll(runs), Task.Delay(TimeSpan.FromSeconds(3), CancellationToken.None)); + } + + foreach (var server in servers) + { + var url = ReadLaunchUrl(server)!; + var run = RunUntilStoppedAsync(new ProcessSpec + { + FileName = "dotnet", + Arguments = ["run", "--project", server, "--no-build", .. project.BuildProperties.Select(p => $"--property:{p}")], + WorkingDirectory = project.Directory, + Environment = new Dictionary { ["DOTNET_NOLOGO"] = "1" } + }, running.Token); + + runs.Add(run); + + if (await WaitUntilAliveAsync(new Uri(url), run, progress, cancellationToken) is { } reason) + { + await StopAsync(); + running.Dispose(); + return StepResult.Warning($"Couldn't start {Path.GetFileName(server)}", reason, $"cd {ProcessSpec.Quote(project.Directory)} && {followUp}"); + } + } + + Running = new RunningProject { AppUrl = ReadLaunchUrl(ServerWebDirectory), StopAsync = _ => StopAsync(), Exited = Task.WhenAny(runs) }; + return StepResult.Succeeded("Started the project", Running.AppUrl); + } + + private async Task RunUntilStoppedAsync(ProcessSpec spec, CancellationToken cancellationToken) + { + try + { + return await Runner.RunAsync(spec, cancellationToken); + } + catch (OperationCanceledException) + { + return null; + } + } + + private async Task WaitUntilAliveAsync(Uri url, Task run, Action progress, CancellationToken cancellationToken) + { + var alive = new Uri(url, "alive"); + var deadline = DateTime.UtcNow.AddMinutes(10); + + progress($"Waiting for {url}"); + + while (DateTime.UtcNow < deadline) + { + if (run.IsCompleted) + return await run is { } result ? DiagnosticCodes.FirstErrorLine(result.Output) ?? $"it exited with code {result.ExitCode}" : "it was stopped"; + + if (await AliveProbe(alive, cancellationToken)) + return null; + + await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken)); + cancellationToken.ThrowIfCancellationRequested(); + } + + return "it didn't answer within 10 minutes"; + } + + private static async Task IsAliveAsync(Uri url, CancellationToken cancellationToken) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + + try + { + using var response = await client.GetAsync(url, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception exp) when (exp is HttpRequestException || (exp is TaskCanceledException && cancellationToken.IsCancellationRequested is false)) + { + return false; + } + } + + public static string? ReadLaunchUrl(string projectDirectory) + { + var path = Path.Combine(projectDirectory, "Properties", "launchSettings.json"); + + try + { + if (File.Exists(path) is false) + return null; + + using var document = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + + if (document.RootElement.TryGetProperty("profiles", out var profiles) is false || profiles.ValueKind is not JsonValueKind.Object) + return null; + + var url = profiles.EnumerateObject() + .Select(profile => profile.Value) + .Where(profile => profile.ValueKind is JsonValueKind.Object && Text(profile, "commandName") is "Project") + .Select(profile => Text(profile, "applicationUrl")) + .FirstOrDefault(urls => string.IsNullOrWhiteSpace(urls) is false)? + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(); + + return url is null ? null : WildcardHostRegex().Replace(url, "://localhost"); + } + catch (Exception exp) when (exp is IOException or JsonException or UnauthorizedAccessException) + { + return null; + } + } + + private async Task AspireResourceUrlAsync(string resource, CancellationToken cancellationToken) + { + var describe = await Aspire(["describe", resource, "--format", "json", "--apphost", project.AppHostDirectory, "--non-interactive", "--nologo"], _ => { }, TimeSpan.FromMinutes(1), cancellationToken); + + var urls = ReadJson(describe.Output, root => root.TryGetProperty("resources", out var all) && all.ValueKind is JsonValueKind.Array + ? all.EnumerateArray() + .Where(r => Text(r, "name") == resource || Text(r, "displayName") == resource) + .SelectMany(r => r.TryGetProperty("urls", out var list) && list.ValueKind is JsonValueKind.Array ? list.EnumerateArray() : Enumerable.Empty()) + .Where(u => u.TryGetProperty("isInternal", out var isInternal) is false || isInternal.ValueKind is not JsonValueKind.True) + .Select(u => Text(u, "url")) + .OfType() + .Where(u => u.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + .ToList() + : null) ?? []; + + return urls.FirstOrDefault(u => u.StartsWith("https:", StringComparison.OrdinalIgnoreCase)) ?? urls.FirstOrDefault(); } private async Task<(string Detail, string? Fix)?> ContainerRuntimeProblemAsync(CancellationToken cancellationToken) @@ -343,7 +533,7 @@ public async Task AspireStartAsync(Action progress, Cancella return (worst.Message ?? "Aspire can't use a container runtime", worst.Fix); } - private async Task WaitUntilHealthyAsync(string resource, Action progress, CancellationToken cancellationToken) + private async Task WaitUntilHealthyAsync(string resource, bool containersRun, Action progress, CancellationToken cancellationToken) { const int roundSeconds = 30; const int maxRounds = 60; @@ -358,7 +548,7 @@ public async Task AspireStartAsync(Action progress, Cancella if (wait.Succeeded) return null; - var resources = await DescribeAspireResourcesAsync(cancellationToken); + var resources = (await DescribeAspireResourcesAsync(cancellationToken)).Where(r => containersRun || r.Type is not "Container").ToList(); var failed = resources.Where(r => r.State is "FailedToStart" || (r.State is "Exited" or "Finished" && r.ExitCode is not (null or 0))).ToList(); if (failed.Count > 0) @@ -382,12 +572,12 @@ public async Task AspireStartAsync(Action progress, Cancella private const int AspireWaitTimedOut = 17; - private async Task> DescribeAspireResourcesAsync(CancellationToken cancellationToken) + private async Task> DescribeAspireResourcesAsync(CancellationToken cancellationToken) { var describe = await Aspire(["describe", "--format", "json", "--apphost", project.AppHostDirectory, "--non-interactive", "--nologo"], _ => { }, TimeSpan.FromMinutes(1), cancellationToken); return ReadJson(describe.Output, root => root.TryGetProperty("resources", out var all) && all.ValueKind is JsonValueKind.Array - ? all.EnumerateArray().Select(r => (Name: Text(r, "displayName") ?? Text(r, "name") ?? "?", State: Text(r, "state"), HealthStatus: Text(r, "healthStatus"), ExitCode: r.TryGetProperty("exitCode", out var code) && code.ValueKind is JsonValueKind.Number ? code.GetInt32() : (int?)null)).ToList() + ? all.EnumerateArray().Select(r => (Name: Text(r, "displayName") ?? Text(r, "name") ?? "?", Type: Text(r, "resourceType"), State: Text(r, "state"), HealthStatus: Text(r, "healthStatus"), ExitCode: r.TryGetProperty("exitCode", out var code) && code.ValueKind is JsonValueKind.Number ? code.GetInt32() : (int?)null)).ToList() : null) ?? []; } @@ -433,7 +623,12 @@ public static IReadOnlyList AspireResourcesToWaitFor(string appHostDirec private Task Aspire(string[] arguments, Action progress, TimeSpan timeout, CancellationToken cancellationToken, IReadOnlyDictionary? environment = null) { - return Runner.RunAsync(new ProcessSpec { FileName = "aspire", Arguments = arguments, WorkingDirectory = project.Directory, Environment = environment, OnOutputLine = progress, Timeout = timeout }, cancellationToken); + return Runner.RunAsync(AspireSpec(arguments, progress, timeout, environment), cancellationToken); + } + + private ProcessSpec AspireSpec(string[] arguments, Action progress, TimeSpan timeout, IReadOnlyDictionary? environment = null) + { + return new ProcessSpec { FileName = "aspire", Arguments = arguments, WorkingDirectory = project.Directory, Environment = environment, OnOutputLine = progress, Timeout = timeout }; } public Task TrustAsync(Action progress, CancellationToken cancellationToken) @@ -752,4 +947,10 @@ private static bool NeedsElevation(string output) [GeneratedRegex(@"[^A-Za-z0-9._-]+")] private static partial Regex GitEmailRegex(); + + [GeneratedRegex(@"""dashboardUrl""\s*:\s*""(?[^""]+)""")] + private static partial Regex DashboardUrlRegex(); + + [GeneratedRegex(@"://(\*|\+|0\.0\.0\.0|\[::\])")] + private static partial Regex WildcardHostRegex(); } diff --git a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs new file mode 100644 index 00000000000..c7468f90a2a --- /dev/null +++ b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs @@ -0,0 +1,265 @@ +using System.Text.RegularExpressions; +using Bit.Cli.Infrastructure; +using Bit.Cli.Templates; +using Spectre.Console; + +namespace Bit.Cli.Projects; + +public sealed record DiffRequest +{ + public string? From { get; init; } + + public string? To { get; init; } + + public string? Project { get; init; } + + public string? Name { get; init; } + + public string? Output { get; init; } + + public string? TemplatePackage { get; init; } + + public IReadOnlyDictionary TemplateValues { get; init; } = new Dictionary(); +} + +public sealed record ProjectRecord(string? Version, string? Name, IReadOnlyDictionary Options, string? SeedPassword); + +public sealed partial class TemplateDiff(CliServices cli) +{ + public async Task RunAsync(DiffRequest request, CancellationToken cancellationToken) + { + NewWorkflow.WriteHeader(cli); + + var directory = Path.GetFullPath(request.Project ?? ".", cli.Environment.CurrentDirectory); + var readme = Path.Combine(directory, "README.md"); + var record = File.Exists(readme) ? ReadReadme(File.ReadAllText(readme)) : new ProjectRecord(null, null, new Dictionary(), null); + var name = request.Name ?? (Directory.Exists(directory) ? ProjectContext.FindProjectName(directory) : null) ?? record.Name; + var from = request.From ?? record.Version; + var to = request.To ?? (request.TemplatePackage is null ? BuildInfo.Version : TemplateSource.FolderVersion); + + if (name is null) + return UsageError($"{directory} isn't a bit Boilerplate project. Run bit diff in the project's folder, or pass --name."); + + if (from is null) + return UsageError($"{readme} doesn't say which bit Boilerplate version made the project. Pass it: bit diff [to]."); + + if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase) && request.TemplatePackage is null) + { + cli.Console.Out.MarkupLine($" [green]{Markup.Escape(name)} is already on bit Boilerplate {Markup.Escape(to)}.[/]"); + return CliApp.ExitOk; + } + + var options = new Dictionary(record.Options, StringComparer.OrdinalIgnoreCase); + + foreach (var (option, value) in request.TemplateValues) + { + options[option] = value; + } + + var seedPassword = options.Remove("seedPassword", out var given) ? given : record.SeedPassword; + var output = Path.GetFullPath(request.Output ?? Path.Combine(cli.Environment.BitDirectory, "diffs", $"{name}-{from}-{to}"), cli.Environment.CurrentDirectory); + + if (Directory.Exists(output) && Directory.EnumerateFileSystemEntries(output).Any()) + { + if (request.Output is not null) + return UsageError($"{output} already exists and isn't empty. Pick another folder with -o."); + + FolderRemover.Delete(output); + } + + Directory.CreateDirectory(output); + + cli.Console.Out.MarkupLine($" Comparing bit Boilerplate [bold]{Markup.Escape(from)}[/] with [bold]{Markup.Escape(to)}[/] for [bold]{Markup.Escape(name)}[/]"); + cli.Console.Out.MarkupLine($" [grey]{(options.Count == 0 ? "the template's default options" : string.Join(" ", options.Select(o => o.Value is "true" ? $"--{o.Key}" : $"--{o.Key} {o.Value}")))}[/]"); + cli.Console.Out.WriteLine(); + + var steps = new StepRunner(cli); + var source = new TemplateSource(cli.Environment, cli.Runner); + + await steps.RunAsync("git", "Creating a git repository", async (_, ct) => + { + var init = await Git(output, ["init", "-q", "-b", "bit-boilerplate"], ct); + + foreach (var (key, value) in new[] { ("user.name", "bit diff"), ("user.email", "diff@bitplatform.dev"), ("core.autocrlf", "false"), ("core.safecrlf", "false"), ("commit.gpgsign", "false") }) + { + await Git(output, ["config", key, value], ct); + } + + return StepResult.FromProcess(init, "Created a git repository", "Couldn't create a git repository", null, output); + }, cancellationToken); + + if (steps.AnyFailed is false) + { + await SnapshotAsync(steps, "from", source, from, null, name, output, options, seedPassword, cancellationToken); + } + + if (steps.AnyFailed is false) + { + await steps.RunAsync("clear", $"Clearing bit Boilerplate {from}", (_, _) => + { + var failures = new DirectoryInfo(output).EnumerateFileSystemInfos().Where(entry => entry.Name is not ".git").SelectMany(entry => Remove(entry)).ToList(); + + return Task.FromResult(failures.Count == 0 + ? StepResult.Succeeded($"Cleared bit Boilerplate {from}") + : StepResult.Failed($"Couldn't clear bit Boilerplate {from}", $"{failures.Count} files are in use", hint: failures[0])); + }, cancellationToken); + } + + if (steps.AnyFailed is false) + { + await SnapshotAsync(steps, "to", source, to, request.TemplatePackage, name, output, options, seedPassword, cancellationToken); + } + + if (steps.AnyFailed) + return CliApp.ExitFailed; + + var patch = output.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + ".patch"; + await Git(output, ["diff", "--binary", $"--output={patch}", "HEAD~1", "HEAD"], cancellationToken); + var stat = (await Git(output, ["diff", "--shortstat", "HEAD~1", "HEAD"], cancellationToken)).Output.Trim(); + + cli.Console.Out.WriteLine(); + cli.Console.Out.MarkupLine($" [green bold]bit Boilerplate {Markup.Escape(from)} → {Markup.Escape(to)}[/][grey]: {Markup.Escape(stat.Length == 0 ? "no changes" : stat)}[/]"); + cli.Console.Out.WriteLine(); + cli.Console.Out.MarkupLine($" [bold]Repository[/] {Markup.Escape(output)} [grey]one commit per version[/]"); + cli.Console.Out.MarkupLine($" [bold]Patch[/] {Markup.Escape(patch)}"); + cli.Console.Out.MarkupLine($" [bold]Files[/] git -C {Markup.Escape(ProcessSpec.Quote(output))} diff --stat HEAD~1"); + cli.Console.Out.WriteLine(); + cli.Console.Out.MarkupLine(" [grey]Both were created with the same name, options and seeded accounts' password. The ports, GUIDs and dates the template picks on every run, and the app certificate bit new replaces, still differ between them.[/]"); + cli.Console.Out.WriteLine(); + + return CliApp.ExitOk; + } + + private async Task SnapshotAsync(StepRunner steps, string id, TemplateSource source, string version, string? packagePath, string name, string output, IReadOnlyDictionary options, string? seedPassword, CancellationToken cancellationToken) + { + await steps.RunAsync($"create-{id}", $"Creating {name} with bit Boilerplate {version}", async (progress, ct) => + { + var (package, installed) = await source.EnsureInstalledAsync(packagePath is null ? version : null, packagePath, progress, ct); + + if (package is null) + return StepResult.FromProcess(installed.Succeeded ? installed with { ExitCode = 1 } : installed, "", $"Couldn't get bit Boilerplate {version}", packagePath is null ? $"dotnet new install Bit.Boilerplate::{version}" : null); + + var manifest = TemplateSource.ReadManifest(package.PackagePath) ?? TemplateManifest.Embedded; + var (arguments, unknown) = TemplateArguments(manifest, options, seedPassword); + var created = await source.CreateAsync(package, manifest, name, output, arguments, progress, ct); + + if (created.Succeeded is false) + return StepResult.FromProcess(created, "", $"Couldn't create {name} with bit Boilerplate {version}", null); + + var solution = Path.Combine(output, $"{name}.slnx"); + var legacySolution = Path.ChangeExtension(solution, ".sln"); + + if (File.Exists(solution) && File.Exists(legacySolution)) + { + File.Delete(legacySolution); + } + + await Git(output, ["add", "-A"], ct); + var commit = await Git(output, ["commit", "-q", "--allow-empty", "-m", $"bit Boilerplate {version}"], ct); + + return StepResult.FromProcess(commit, $"Created {name} with bit Boilerplate {version}", $"Couldn't commit bit Boilerplate {version}", null, + unknown.Count == 0 ? null : $"without {string.Join(", ", unknown)}, which it doesn't have"); + }, cancellationToken); + } + + public static (List Arguments, List Unknown) TemplateArguments(TemplateManifest manifest, IReadOnlyDictionary options, string? seedPassword) + { + var arguments = new List(); + var unknown = new List(); + + foreach (var (option, value) in options) + { + if (manifest.Find(option) is not { } parameter || parameter.Normalize(value) is not { } normalized) + { + unknown.Add($"--{option} {value}"); + continue; + } + + arguments.AddRange([$"--{parameter.Name}", normalized]); + } + + if (seedPassword is not null && manifest.Find("seedPassword") is not null) + { + arguments.AddRange(["--seedPassword", seedPassword]); + } + + return (arguments, unknown); + } + + public static ProjectRecord ReadReadme(string readme) + { + var version = VersionRegex().Match(readme) is { Success: true } versionMatch ? versionMatch.Groups["version"].Value : null; + var password = PasswordRegex().Match(readme) is { Success: true } passwordMatch ? passwordMatch.Groups["password"].Value : null; + var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + string? name = null; + + if (CommandRegex().Match(readme) is { Success: true } commandMatch) + { + var tokens = commandMatch.Groups["command"].Value.Split((char[])[' ', '\t', '\r', '\n', '\\', '`'], StringSplitOptions.RemoveEmptyEntries); + var start = Array.FindIndex(tokens, token => token is "new"); + var first = start > 0 && tokens[start - 1] is "dotnet" ? start + 2 : start + 1; + + for (var i = first; i < tokens.Length; i++) + { + var token = tokens[i]; + + if (token.StartsWith('-') is false) + { + name ??= token; + continue; + } + + var option = token.TrimStart('-'); + var value = i + 1 < tokens.Length && tokens[i + 1].StartsWith('-') is false ? tokens[++i] : "true"; + + if (option is "name" or "n") + { + name = value; + } + else if (option is not ("output" or "o" or "yes" or "y")) + { + options[option] = value; + } + } + } + + return new ProjectRecord(version, name, options, password); + } + + private static IEnumerable Remove(FileSystemInfo entry) + { + if (entry is DirectoryInfo) + return FolderRemover.Delete(entry.FullName).Failures.Select(f => f.Path); + + try + { + entry.Attributes = FileAttributes.Normal; + entry.Delete(); + return []; + } + catch (Exception exp) when (exp is IOException or UnauthorizedAccessException) + { + return [entry.FullName]; + } + } + + private Task Git(string directory, string[] arguments, CancellationToken cancellationToken) + { + return cli.Runner.RunAsync(new ProcessSpec { FileName = "git", Arguments = arguments, WorkingDirectory = directory, Timeout = TimeSpan.FromMinutes(5) }, cancellationToken); + } + + private int UsageError(string message) + { + cli.Console.Fail(message); + return CliApp.ExitUsage; + } + + [GeneratedRegex(@"generated by bit \S+ v-?(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)", RegexOptions.IgnoreCase)] + private static partial Regex VersionRegex(); + + [GeneratedRegex(@"```[a-z]*\s*(?(?:bit|dotnet)\s+new\s[^`]*)```", RegexOptions.IgnoreCase)] + private static partial Regex CommandRegex(); + + [GeneratedRegex(@"password is \*\*`(?[^`]+)`\*\*", RegexOptions.IgnoreCase)] + private static partial Regex PasswordRegex(); +} diff --git a/src/Cli/Bit.Cli/Templates/TemplateLabels.cs b/src/Cli/Bit.Cli/Templates/TemplateLabels.cs index 8b7f0cf8fda..cf87b8b389f 100644 --- a/src/Cli/Bit.Cli/Templates/TemplateLabels.cs +++ b/src/Cli/Bit.Cli/Templates/TemplateLabels.cs @@ -25,7 +25,8 @@ public static class TemplateLabels ["theme"] = "Theme", ["apiServerUrl"] = "API server URL", ["webAppUrl"] = "Web app URL", - ["advancedTests"] = "Advanced automated tests" + ["advancedTests"] = "Advanced automated tests", + ["seedPassword"] = "Seeded accounts' password" }; public static string For(TemplateParameter parameter) diff --git a/src/Cli/Bit.Cli/Templates/TemplateSelection.cs b/src/Cli/Bit.Cli/Templates/TemplateSelection.cs index eec74355680..56e683f22a4 100644 --- a/src/Cli/Bit.Cli/Templates/TemplateSelection.cs +++ b/src/Cli/Bit.Cli/Templates/TemplateSelection.cs @@ -14,6 +14,8 @@ public sealed class TemplateSelection(TemplateManifest manifest) public string Database => this["database"]; + public bool NeedsContainers => Aspire && (Database is "SqlServer" or "PostgreSQL" or "MySql" || IsTrue("redis")); + public IReadOnlyList AspireContainers() { if (Aspire is false) diff --git a/src/Cli/Bit.Cli/Tools/Hardware.cs b/src/Cli/Bit.Cli/Tools/Hardware.cs index e78a9687e46..43216ef8551 100644 --- a/src/Cli/Bit.Cli/Tools/Hardware.cs +++ b/src/Cli/Bit.Cli/Tools/Hardware.cs @@ -21,7 +21,7 @@ public static partial class Hardware private const long Gibibyte = 1024L * 1024 * 1024; - private const long RecommendedMemoryBytes = 24 * Gibibyte; + private const long RecommendedMemoryBytes = 16 * Gibibyte; private const long ReportedMemoryShortfall = Gibibyte; @@ -49,14 +49,14 @@ public static IReadOnlyList Evaluate(HardwareFacts facts, ToolN { var warnings = new List(); var emulator = needs.Platforms.Contains(Platform.Android); - var dockerDesktop = needs.Aspire && os is not HostOs.Linux; + var containers = needs.ContainersRequired && os is not HostOs.Linux; - if (facts.VirtualizationEnabled is false && (emulator || dockerDesktop)) + if (facts.VirtualizationEnabled is false && (emulator || containers)) { - var users = (dockerDesktop, emulator) switch + var users = (containers, emulator) switch { - (true, true) => "Docker Desktop and the Android emulator need it", - (true, false) => "Docker Desktop needs it", + (true, true) => "the containers Aspire runs and the Android emulator need it", + (true, false) => "the containers Aspire runs need it", _ => "the Android emulator needs it" }; @@ -67,7 +67,7 @@ public static IReadOnlyList Evaluate(HardwareFacts facts, ToolN if (facts.MemoryBytes is { } memory && memory < RecommendedMemoryBytes - ReportedMemoryShortfall) { - warnings.Add(new("memory", $"This machine has {Math.Round((double)memory / Gibibyte)} GB of memory. With Aspire's containers, the IDE, a browser and an emulator running, 24 GB or more keeps things smooth.")); + warnings.Add(new("memory", $"This machine has {Math.Round((double)memory / Gibibyte)} GB of memory. With Aspire's containers, the IDE, a browser and an emulator running, 16 GB or more keeps things smooth.")); } if (facts.OnHardDisk is true) diff --git a/src/Cli/Bit.Cli/Tools/ToolCatalog.cs b/src/Cli/Bit.Cli/Tools/ToolCatalog.cs index b15f0208021..8f4b8d66fc4 100644 --- a/src/Cli/Bit.Cli/Tools/ToolCatalog.cs +++ b/src/Cli/Bit.Cli/Tools/ToolCatalog.cs @@ -509,7 +509,7 @@ private sealed class WslTool : Tool public override bool AppliesTo(ToolContext context) => context.Environment.IsWindows && context.Needs.Aspire; - public override string Why(ToolContext context) => "Docker Desktop runs its containers in WSL 2"; + public override string Why(ToolContext context) => "Podman runs its containers in WSL 2"; public override bool IsNeeded(ToolContext context) => context.Needs.Aspire; @@ -519,33 +519,43 @@ public override async Task DetectAsync(ToolContext context, Cancella return result.Succeeded ? ToolStatus.Installed() : ToolStatus.Missing(); } - public override ToolAction? PlanInstall(ToolContext context, ToolStatus status) => context.PackageManagers.WingetInstall(Id, "Install WSL", "Microsoft.WSL", afterInstall: "Restart Windows to finish installing WSL.") is { } winget - ? winget with { Commands = [EnableFeatureCommand("VirtualMachinePlatform"), .. winget.Commands] } - : new() - { - ToolId = Id, - Title = "Install WSL", - Elevation = Elevation.Admin, - AfterInstall = "Restart Windows to finish installing WSL.", - SuccessExitCodes = [0, 3010], - Commands = [new ProcessSpec { FileName = "wsl.exe", Arguments = ["--install", "--no-distribution"], Timeout = TimeSpan.FromMinutes(30) }] - }; + public override ToolAction? PlanInstall(ToolContext context, ToolStatus status) + { + var action = context.PackageManagers.WingetInstall(Id, "Install WSL", "Microsoft.WSL", afterInstall: "Restart Windows to finish installing WSL.") is { } winget + ? winget with { Commands = [EnableFeatureCommand("VirtualMachinePlatform"), .. winget.Commands] } + : new() + { + ToolId = Id, + Title = "Install WSL", + Elevation = Elevation.Admin, + AfterInstall = "Restart Windows to finish installing WSL.", + SuccessExitCodes = [0, 3010], + Commands = [new ProcessSpec { FileName = "wsl.exe", Arguments = ["--install", "--no-distribution"], Timeout = TimeSpan.FromMinutes(30) }] + }; + + return action with { Optional = context.Needs.ContainersRequired is false }; + } public override string? ManualInstructions(ToolContext context) => "https://learn.microsoft.com/windows/wsl/install"; } private sealed class DockerTool : Tool { + private const string PodmanWithoutMachine = "Podman has no machine yet"; + + private const string PodmanMachineStopped = "Podman's machine isn't running"; + public override string Id => "docker"; - public override string Name => "Docker"; + public override string Name => "Docker or Podman"; public override bool AppliesInCi(ToolContext context) => false; public override string Why(ToolContext context) => context.Needs switch { { Aspire: false } => "runs containers; this project doesn't need it without Aspire", - { Containers.Count: > 0 } needs => $"Aspire runs {Join(needs.Containers)} in containers", + { ContainersRequired: true, Containers.Count: > 0 } needs => $"Aspire runs {Join(needs.Containers)} in containers", + { Containers.Count: > 0 } needs => $"Aspire runs {Join(needs.Containers)} in containers; the app also runs without them", _ => "Aspire runs the database, Keycloak, Mailpit and other resources in containers" }; @@ -560,16 +570,32 @@ public override async Task DetectAsync(ToolContext context, Cancella if (server.Succeeded && ParseVersion(server.Output) is { } version) return ToolStatus.Installed(version.ToString()); + if (FindPodman(context) is { } podman) + { + if ((await context.RunAsync(podman, ["container", "ls", "-n", "1"], cancellationToken, TimeSpan.FromSeconds(20))).Succeeded) + return ToolStatus.Installed($"Podman {ParseVersion((await context.RunAsync(podman, ["--version"], cancellationToken)).Output)}".Trim()); + + if (context.Environment.IsWindows) + return new ToolStatus(ToolState.NotRunning, null, (await context.RunAsync(podman, ["machine", "inspect"], cancellationToken)).Succeeded ? PodmanMachineStopped : PodmanWithoutMachine); + } + var client = await context.RunAsync("docker", ["--version"], cancellationToken); return client.Succeeded ? new ToolStatus(ToolState.NotRunning, ParseVersion(client.Output)?.ToString(), "installed, but not running") : ToolStatus.Missing(); } public override ToolAction? PlanInstall(ToolContext context, ToolStatus status) + { + return Plan(context, status) is { } action ? action with { Optional = context.Needs.ContainersRequired is false } : null; + } + + private ToolAction? Plan(ToolContext context, ToolStatus status) { if (status.State is ToolState.NotRunning) { return context.Environment.Os switch { + HostOs.Windows when status.Detail is PodmanWithoutMachine or PodmanMachineStopped && FindPodman(context) is { } podman + => new ToolAction { ToolId = Id, Title = "Start Podman", Commands = PodmanMachine(podman, init: status.Detail is PodmanWithoutMachine) }, HostOs.Windows when FindDockerDesktop(context) is { } desktop => new ToolAction { ToolId = Id, @@ -577,7 +603,11 @@ public override async Task DetectAsync(ToolContext context, Cancella Commands = [new ProcessSpec { FileName = "cmd.exe", Arguments = ["/c", "start", "", desktop] }], AfterInstall = "Docker Desktop is starting; accept its terms if it asks." }, - HostOs.MacOS => new ToolAction { ToolId = Id, Title = "Start Docker Desktop", Commands = [new ProcessSpec { FileName = "open", Arguments = ["-a", "Docker"] }] }, + HostOs.MacOS when context.Runner.FindExecutable("colima") is { } colima + => new ToolAction { ToolId = Id, Title = "Start Colima", Commands = [new ProcessSpec { FileName = colima, Arguments = ["start"], Timeout = TimeSpan.FromMinutes(15) }] }, + HostOs.MacOS when Directory.Exists("/Applications/Docker.app") + => new ToolAction { ToolId = Id, Title = "Start Docker Desktop", Commands = [new ProcessSpec { FileName = "open", Arguments = ["-a", "Docker"] }] }, + HostOs.MacOS => Colima(context), HostOs.Linux => new ToolAction { ToolId = Id, Title = "Start Docker", Elevation = Elevation.Sudo, Commands = [PackageManagers.Sudo("systemctl", "start", "docker")] }, _ => null }; @@ -585,8 +615,10 @@ public override async Task DetectAsync(ToolContext context, Cancella return context.Environment.Os switch { - HostOs.Windows => context.PackageManagers.WingetInstall(Id, "Install Docker Desktop", "Docker.DockerDesktop", afterInstall: "Start Docker Desktop once; Windows may need a restart first.", installerArguments: "install --quiet --accept-license"), - HostOs.MacOS => context.PackageManagers.BrewInstall(Id, "Install Docker Desktop", "docker", cask: true, afterInstall: "Open Docker Desktop once and accept its terms."), + HostOs.Windows => context.PackageManagers.WingetInstall(Id, "Install Podman", "Podman.CLI", admin: false) is { } winget + ? winget with { Commands = [.. winget.Commands, .. PodmanMachine(PodmanInstallPath(context), init: true)] } + : null, + HostOs.MacOS => Colima(context), _ when context.PackageManagers.Apt is not null || context.PackageManagers.Dnf is not null => new ToolAction { ToolId = Id, @@ -600,11 +632,57 @@ public override async Task DetectAsync(ToolContext context, Cancella PackageManagers.Sudo("systemctl", "enable", "--now", "docker") ] }, - _ => context.PackageManagers.LinuxInstall(Id, "Install Docker", "docker.io", "docker", "docker") + _ => context.PackageManagers.LinuxInstall(Id, "Install Docker", "docker.io docker-buildx", "docker docker-buildx", "docker docker-buildx") }; } - public override string? ManualInstructions(ToolContext context) => context.Environment.IsLinux ? "https://docs.docker.com/engine/install/" : "https://www.docker.com/products/docker-desktop/"; + public override string? ManualInstructions(ToolContext context) => context.Environment.Os switch + { + HostOs.Windows => "https://podman.io/docs/installation#windows", + HostOs.MacOS => "https://github.com/abiosoft/colima#installation", + _ => "https://docs.docker.com/engine/install/" + }; + + private static IReadOnlyList PodmanMachine(string podman, bool init) + { + var startAtSignIn = $"powershell.exe -NoProfile -WindowStyle Hidden -Command \"& '{podman.Replace("'", "''", StringComparison.Ordinal)}' machine start\""; + + return + [ + new ProcessSpec { FileName = podman, Arguments = init ? ["machine", "init", "--now"] : ["machine", "start"], Timeout = TimeSpan.FromMinutes(30) }, + new ProcessSpec { FileName = "reg.exe", Arguments = ["add", @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Podman", "/t", "REG_SZ", "/d", startAtSignIn, "/f"] } + ]; + } + + private static ToolAction? Colima(ToolContext context) + { + if (context.PackageManagers.Brew is not { } brew) + return null; + + return new ToolAction + { + ToolId = "docker", + Title = "Install Colima and Docker", + Commands = + [ + new ProcessSpec { FileName = brew, Arguments = ["install", "colima", "docker", "docker-buildx"], Timeout = TimeSpan.FromMinutes(30) }, + new ProcessSpec { FileName = "/bin/sh", Arguments = ["-c", "mkdir -p \"$HOME/.docker/cli-plugins\" && ln -sfn \"$(\"$0\" --prefix)/opt/docker-buildx/bin/docker-buildx\" \"$HOME/.docker/cli-plugins/docker-buildx\"", brew] }, + new ProcessSpec { FileName = "/bin/sh", Arguments = ["-c", "colima=\"$(dirname \"$0\")/colima\" && \"$colima\" start --cpu 4 --memory 6 --mount \"$HOME:w\" --mount /var/folders:w && \"$colima\" stop && \"$0\" services start colima", brew], Timeout = TimeSpan.FromMinutes(30) } + ] + }; + } + + private static string? FindPodman(ToolContext context) + { + if (context.Runner.FindExecutable("podman") is { } podman) + return podman; + + var installed = PodmanInstallPath(context); + return context.Environment.IsWindows && File.Exists(installed) ? installed : null; + } + + private static string PodmanInstallPath(ToolContext context) + => Path.Combine(context.Environment.GetVariable("LOCALAPPDATA") ?? Path.Combine(context.Environment.HomeDirectory, "AppData", "Local"), "Programs", "Podman", "podman.exe"); private static string? FindDockerDesktop(ToolContext context) { diff --git a/src/Cli/Bit.Cli/Tools/ToolInstaller.cs b/src/Cli/Bit.Cli/Tools/ToolInstaller.cs index c6939edb45a..9d0bf161f3c 100644 --- a/src/Cli/Bit.Cli/Tools/ToolInstaller.cs +++ b/src/Cli/Bit.Cli/Tools/ToolInstaller.cs @@ -109,7 +109,9 @@ await steps.RunAsync(action.ToolId, action.Title, async (progress, ct) => } if (succeeded) - return StepResult.Succeeded(done, hint: action.AfterInstall) with { Status = action.AfterInstall is null ? StepStatus.Succeeded : StepStatus.Warning, Detail = action.AfterInstall }; + return action.AfterInstall is null || action.Optional + ? StepResult.Succeeded(done, action.AfterInstall) + : StepResult.Warning(done, action.AfterInstall, hint: action.AfterInstall); if (action.Partial is { } partial && last is { NotFound: false, TimedOut: false } && last.ExitCode == partial.ExitCode) return StepResult.Succeeded(done, partial.Detail, partial.Hint); @@ -265,8 +267,8 @@ await cli.Console.RunWithStatusAsync(title, async progress => } else if (exitCodes.TryGetValue(action.ToolId, out var exitCode) && action.SuccessExitCodes.Contains(exitCode)) { - steps.Add(action.ToolId, action.AfterInstall is null - ? StepResult.Succeeded(done) + steps.Add(action.ToolId, action.AfterInstall is null || action.Optional + ? StepResult.Succeeded(done, action.AfterInstall) : StepResult.Warning(done, action.AfterInstall)); } else @@ -275,7 +277,8 @@ await cli.Console.RunWithStatusAsync(title, async progress => ? code switch { TimedOutCode => "it took too long and was stopped", CancelledCode => "it was cancelled", _ => $"exit code {code}" } : "it didn't run"; - steps.Add(action.ToolId, StepResult.Failed($"Couldn't {char.ToLowerInvariant(action.Title[0])}{action.Title[1..]}", detail, followUp, hint: AdministratorOutput(log, action.Title))); + var failed = StepResult.Failed($"Couldn't {char.ToLowerInvariant(action.Title[0])}{action.Title[1..]}", detail, followUp, hint: AdministratorOutput(log, action.Title)); + steps.Add(action.ToolId, action.Optional ? failed with { Status = StepStatus.Warning } : failed); } } } diff --git a/src/Cli/Bit.Cli/Tools/ToolModel.cs b/src/Cli/Bit.Cli/Tools/ToolModel.cs index 22d2635dd13..8638024c259 100644 --- a/src/Cli/Bit.Cli/Tools/ToolModel.cs +++ b/src/Cli/Bit.Cli/Tools/ToolModel.cs @@ -80,6 +80,8 @@ public sealed record ToolNeeds public IReadOnlyList Containers { get; init; } = []; + public bool ContainersRequired { get; init; } + public bool NativeWebAssembly { get; init; } public bool GitHubRepo { get; init; } diff --git a/src/Cli/README.md b/src/Cli/README.md index c98904cfc98..213b5457ef3 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -20,8 +20,8 @@ It needs a .NET 10 SDK or later. `dnx` asks once before it downloads the package `bit new` creates a project from [bit Boilerplate](https://bitplatform.dev/templates) and gets it ready, so the first run works: 1. Takes the project name and options from its arguments. The [create project page](https://bitplatform.dev/templates/create-project) builds the whole command. When a newer bit is out, the run continues with it, see [Staying up to date](#staying-up-to-date). -2. Checks this machine, lists what the project needs and is missing, and installs what you tick: the .NET SDK the template's `global.json` pins, nuget.org as a package source when it's missing or disabled, Node.js, Docker with its license already accepted, WSL and the Windows Virtual Machine Platform that WSL 2 and Docker Desktop run on, Git, the Aspire CLI, the HTTPS development certificate, VS Code, the GitHub CLI when you ask for a GitHub repository, Windows long paths, and for native apps the Windows Hypervisor Platform the Android emulator uses and Windows developer mode, plus Python 3 on Linux when the offline database's native WebAssembly build needs it. Things already installed aren't shown, and neither is anything that doesn't apply to your operating system. The development certificate is trusted first, because Windows and macOS ask you to confirm it, so once that and the administrator prompt are answered you can leave it to finish. The versions come from the project: the .NET SDK from `global.json`, Node.js from `engines.node` in Client.Core's `package.json`, and the Aspire CLI from the AppHost's Aspire version, so an older Aspire CLI is updated to it. When nvm, fnm or Volta manages Node.js, Node.js is installed or updated through it, so the version it picks is the one that runs. On macOS with a native app, it also checks that Xcode is the one the project's .NET for iOS asks for. -3. Warns, only when it's sure, about hardware that makes development slow: virtualization turned off in the BIOS or UEFI while Docker Desktop or the Android emulator needs it, less than 24 GB of memory, or a project drive that is a hard disk rather than an SSD. Drives are judged only when they're internal and on real hardware; USB drives, virtual disks and virtual machines are left alone. +2. Checks this machine, lists what the project needs and is missing, and installs what you tick: the .NET SDK the template's `global.json` pins, nuget.org as a package source when it's missing or disabled, Node.js, a container runtime that is free for commercial use (Podman on Windows, Colima with the docker CLI on macOS, Docker Engine on Linux; a Docker you already have is used as it is), WSL and the Windows Virtual Machine Platform that Podman runs on, Git, the Aspire CLI, the HTTPS development certificate, VS Code, the GitHub CLI when you ask for a GitHub repository, Windows long paths, and for native apps the Windows Hypervisor Platform the Android emulator uses and Windows developer mode, plus Python 3 on Linux when the offline database's native WebAssembly build needs it. Things already installed aren't shown, and neither is anything that doesn't apply to your operating system. A project that runs without its containers, such as one with SQLite and no Redis, still gets them, but a restart they ask for is a note, not a warning. The development certificate is trusted first, because Windows and macOS ask you to confirm it, so once that and the administrator prompt are answered you can leave it to finish. The versions come from the project: the .NET SDK from `global.json`, Node.js from `engines.node` in Client.Core's `package.json`, and the Aspire CLI from the AppHost's Aspire version, so an older Aspire CLI is updated to it. When nvm, fnm or Volta manages Node.js, Node.js is installed or updated through it, so the version it picks is the one that runs. On macOS with a native app, it also checks that Xcode is the one the project's .NET for iOS asks for. +3. Warns, only when it's sure, about hardware that makes development slow: virtualization turned off in the BIOS or UEFI while the project's containers or the Android emulator need it, less than 15 GB of memory, or a project drive that is a hard disk rather than an SSD. Drives are judged only when they're internal and on real hardware; USB drives, virtual disks and virtual machines are left alone. 4. Creates the project, with a development certificate of its own, and keeps only its `.slnx`: the `.sln` the template also ships for older Visual Studio versions is removed. 5. Initializes git with `develop` and `main`, and commits. When git doesn't know your name yet, it commits as your user name and `@git.com`, set on this repository only. On Windows it also turns on git's own long path support (`core.longpaths`), which Windows long paths don't cover. 6. Checks that `dotnet` picks an SDK the project's `global.json` accepts; when it doesn't, the steps that need one are skipped with a single note instead of failing one by one. Then installs the build tools the chosen platforms need (.NET workloads), restores NuGet packages, and builds, which also generates the CSS and JS. With only the web app it builds `Web.slnf`; once any native app is picked it builds the whole `.slnx`, every app the MAUI project targets on this OS included, so it also installs the MAUI build tools and the Android SDK. When the build fails with Razor errors, it restarts the C# compiler server and builds once more: a compiler server that has gone bad reports Razor errors in files that are fine, and keeps doing so for every build until it restarts. Then it installs the Chromium that Playwright runs the UI tests with. @@ -30,8 +30,9 @@ It needs a .NET 10 SDK or later. `dnx` asks once before it downloads the package 9. With `--github-repo`, signs you in to GitHub in your browser when needed, creates a private repository named after the project, pushes `develop` and `main` to it, and makes `develop` its default branch. 10. Marks the folder as trusted for VS Code, Claude Code, Copilot CLI, Codex and Gemini CLI, so the project's tasks and MCP servers work without prompts. That works even when VS Code was just installed and has never run. 11. Installs the VS Code extensions the project recommends, like C# Dev Kit and Claude Code. Copilot is built into VS Code, so it isn't one of them. -12. With Aspire, starts the project once with `aspire start`, waits until its server is healthy and stops it with `aspire stop`, so the container images are already pulled when you first start it from the IDE. It comes last, so everything else is ready however long it takes. It asks `aspire doctor` first whether Aspire can use Docker, and when it can't, says why and how to fix it instead of starting. While it waits, it shows which resources are still starting or unhealthy, and it stops waiting as soon as one fails to start or Docker can't run the containers. +12. Starts the project: with Aspire, `aspire start`; without it, `dotnet run` of Server.Web (and of Server.Api when the API is standalone). It waits until the servers are healthy, comes last so everything else is ready however long it takes, and while it waits, shows which resources are still starting or unhealthy, and stops waiting as soon as one fails to start. It asks `aspire doctor` first whether Aspire can use Docker or Podman. Without one, a project whose servers wait for a container (SQL Server, PostgreSQL, MySQL or Redis) isn't started, with why and how to fix it; any other project starts without its containers, so only Keycloak, Mailpit and the like are missing. 13. Opens the project in VS Code, or the IDE you pick. All that's left is signing in to Claude or Copilot. +14. Opens the Aspire dashboard and then the app in your browser, and keeps the project running while the terminal you ran bit in is open. Close it, or press Ctrl+C, to stop the project. Without a terminal, such as under a coding agent or in a dev container, it only starts the project once with Aspire, so the container images are pulled, and stops it; CI starts nothing. A step that fails doesn't stop the rest: every step runs, and the summary lists the commands that finish whatever didn't work. The first run takes a few minutes, mostly build tools and the first build. @@ -70,6 +71,7 @@ Every option of the bit Boilerplate template works the same way: | `--advancedTests` | `true`, `false` | `false` | | `--apiServerUrl` | a URL | | | `--webAppUrl` | a URL | | +| `--seedPassword` | the seeded accounts' password | a random 8 digit number, shown at the end and in the project's README | And these of its own: @@ -87,7 +89,7 @@ And these of its own: | `--non-interactive` | Never ask; fail when a required value is missing. | | `--dry-run` | Show the plan and change nothing. | | `--no-setup` | Only create the project: nothing is installed, restored or built. Run `bit setup` in its folder later. | -| `--no-tools`, `--no-certificate`, `--no-git`, `--no-workloads`, `--no-restore`, `--no-build`, `--no-browsers`, `--no-format`, `--no-migration`, `--no-trust`, `--no-open` | Skip that step. | +| `--no-tools`, `--no-certificate`, `--no-git`, `--no-workloads`, `--no-restore`, `--no-build`, `--no-browsers`, `--no-format`, `--no-migration`, `--no-trust`, `--no-open`, `--no-run` | Skip that step. | | `-p, --property ` | An MSBuild property for the restore and the build, e.g. `-p:EnforceCodeStyleInBuild=true`. Repeat it for more. `bit setup` takes it too. | ## bit setup @@ -99,7 +101,25 @@ bit setup bit setup --platforms android ``` -It installs missing tools, build tools and packages, builds, installs Playwright's Chromium for the UI tests, starts the project once with Aspire when it has an AppHost, and installs the VS Code extensions the project recommends when VS Code is installed. +It installs missing tools, build tools and packages, builds, installs Playwright's Chromium for the UI tests, installs the VS Code extensions the project recommends when VS Code is installed, and starts the project the way `bit new` does. + +## bit diff + +Shows what changed in bit Boilerplate between two versions, for your project: + +```bash +bit diff +bit diff 10.6.2 10.7.0 +bit diff --template-package ../bitplatform/src/Templates/Boilerplate/Bit.Boilerplate +``` + +- It creates the project with each version, the old one first, into one git repository, so the second commit is exactly the upgrade, and writes it as a patch file next to the repository too. +- In a project, it takes the name, the version, the options and the seeded accounts' password from the project's `README.md`, where `bit new` records them. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. +- An option a version doesn't have is left out for that version, and said so. +- The ports, GUIDs and dates the template picks on every run still differ between the two, and so do the app certificate and the formatting `bit new` adds. +- The repository goes to `~/.bitplatform/diffs/--`, or `-o `. + +The project's `upgrade` skill, for the AI coding tools, runs it and applies the changes to your code. ## In CI @@ -120,7 +140,7 @@ A job that publishes lets bit build first too, so its CSS and JS are generated b In CI, bit installs what a build and its tests need and leaves alone what only a developer's machine needs: -- Docker, WSL, the Aspire CLI and Windows features aren't installed, and the project isn't started with Aspire. +- Docker or Podman, WSL, the Aspire CLI and Windows features aren't installed, and the project isn't started. - The HTTPS development certificate is trusted on Linux only. There it's trusted for some clients, and the step notes that .NET's own HTTPS calls also need `~/.aspnet/dev-certs/trust` in `SSL_CERT_DIR`; any other failure is a warning. - MAUI's `InstallAndroidDependencies` completes the runner's Android SDK, so a pipeline needs no `sdkmanager` step. - Playwright gets every browser with its system libraries, since CI may test more than Chromium. @@ -256,7 +276,8 @@ Without a map, the newest `obj/**/bit-minifier.map` under the current folder is - **The .NET SDK** a project pins, next to the SDKs you already have: with `winget` on Windows (Microsoft's `dotnet-install.ps1` when winget doesn't list that build yet), Microsoft's installer package on macOS after checking it's signed by Microsoft, and Microsoft's `dotnet-install.sh` on Linux. - **VS Code extensions** the project's `.vscode/extensions.json` recommends, only the missing ones, with `code --install-extension`. - **Playwright's Chromium**, in Playwright's own browser folder, with the driver the project's tests were built with. -- **Container images** the project's AppHost uses, which Docker pulls during the first Aspire start and keeps. +- **A container runtime**, when you tick it: on Windows, Podman for your user only, its WSL machine, and a sign-in entry under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` that starts that machine; on macOS, Colima, the docker CLI and buildx, with Colima started at sign-in by `brew services`; on Linux, Docker Engine. +- **Container images** the project's AppHost uses, which Docker or Podman pulls during the first Aspire start and keeps. - **Administrator rights**: on Windows, the steps that need them (long paths, WSL, installers that need admin) run in one elevated PowerShell, so Windows asks once. It runs only the script bit wrote, checked by its hash, gives each command a time limit, and Ctrl+C stops it. On macOS and Linux, `sudo` asks for your password once. - **A private GitHub repository**, only with `--github-repo`, on the account you sign in with. The GitHub CLI keeps that sign-in, and git uses it to push. - **Trust entries**, only for the folder `bit new` created or the one you pass to `bit trust`: `projects` in `~/.claude.json`, `trustedFolders` in `~/.copilot/config.json`, `[projects]` in `~/.codex/config.toml`, `~/.gemini/trustedFolders.json`, and VS Code's trust store in `~/.vscode-shared/sharedStorage/state.vscdb` (only while VS Code isn't running). Each file keeps everything else in it. To undo, delete the entry, or use each tool's own trust settings. diff --git a/src/Cli/Tests/Bit.Cli.Tests/CommandTests.cs b/src/Cli/Tests/Bit.Cli.Tests/CommandTests.cs index 5d50c0ea690..a31248d6025 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/CommandTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/CommandTests.cs @@ -13,7 +13,7 @@ public void TheRootCommand_Should_ListEveryCommand() var root = CliApp.BuildRootCommand(() => throw new InvalidOperationException()); CollectionAssert.AreEquivalent( - new[] { "new", "setup", "doctor", "trust", "clean", "rename", "remove", "translate", "decode", "telemetry", "update", "about" }, + new[] { "new", "setup", "diff", "doctor", "trust", "clean", "rename", "remove", "translate", "decode", "telemetry", "update", "about" }, root.Subcommands.Select(c => c.Name).ToArray()); } diff --git a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs new file mode 100644 index 00000000000..35ff2e2088c --- /dev/null +++ b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs @@ -0,0 +1,83 @@ +using Bit.Cli.Projects; +using Bit.Cli.Templates; + +namespace Bit.Cli.Tests; + +[TestClass] +public class DiffTests +{ + [TestMethod] + public void TheReadme_Should_GiveTheVersionNameOptionsAndPassword() + { + var record = TemplateDiff.ReadReadme(""" + ## Welcome! + + This project is generated by bit Contoso v-10.7.0-pre-02 using the following command: + + ```bash + bit new Contoso + --database PostgreSQL + --module Admin + --aspire false + --sample + ``` + + pre-confirmed accounts whose password is **`40817263`**: + """); + + Assert.AreEqual("10.7.0-pre-02", record.Version); + Assert.AreEqual("Contoso", record.Name); + Assert.AreEqual("40817263", record.SeedPassword); + CollectionAssert.AreEquivalent(new Dictionary { ["database"] = "PostgreSQL", ["module"] = "Admin", ["aspire"] = "false", ["sample"] = "true" }, record.Options.ToDictionary()); + } + + [TestMethod] + public void AnOlderReadme_Should_GiveTheDotnetNewCommand() + { + var record = TemplateDiff.ReadReadme(""" + This project gets generated by bit Contoso v-9.11.2 using the following command + ```bash + dotnet new bit-bp --name Contoso --database SqlServer --signalR + ``` + """); + + Assert.AreEqual("9.11.2", record.Version); + Assert.AreEqual("Contoso", record.Name); + Assert.IsNull(record.SeedPassword); + CollectionAssert.AreEquivalent(new Dictionary { ["database"] = "SqlServer", ["signalR"] = "true" }, record.Options.ToDictionary()); + } + + [TestMethod] + public void EachVersion_Should_GetOnlyTheOptionsItHas() + { + var options = new Dictionary { ["database"] = "postgresql", ["madeUp"] = "true", ["module"] = "Nope" }; + + var (arguments, unknown) = TemplateDiff.TemplateArguments(TemplateManifest.Embedded, options, "40817263"); + + CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL", "--seedPassword", "40817263" }, arguments); + CollectionAssert.AreEquivalent(new[] { "--madeUp true", "--module Nope" }, unknown); + + var older = TemplateManifest.Parse("""{ "identity": "Bit.Boilerplate", "shortName": "bit-bp", "sourceName": "Boilerplate", "symbols": { "database": { "type": "parameter", "datatype": "choice", "defaultValue": "Sqlite", "choices": [ { "choice": "Sqlite" }, { "choice": "PostgreSQL" } ] } } }"""); + CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL" }, TemplateDiff.TemplateArguments(older, options, "40817263").Arguments, "A version without the seeded accounts' password option has 123456 written into it."); + } + + [TestMethod] + public void TheSeedPassword_Should_BeRandomUnlessGiven() + { + var manifest = TemplateManifest.Embedded; + var arguments = new List(); + + var password = NewWorkflow.SeedPassword(manifest, new TemplateSelection(manifest), arguments); + + Assert.IsNotNull(password); + StringAssert.Matches(password, new System.Text.RegularExpressions.Regex(@"^\d{8}$")); + CollectionAssert.AreEqual(new[] { "--seedPassword", password }, arguments); + + var selection = new TemplateSelection(manifest); + selection.Set("seedPassword", "11112222"); + var given = new List(selection.ToTemplateArguments()); + + Assert.AreEqual("11112222", NewWorkflow.SeedPassword(manifest, selection, given)); + Assert.AreEqual(1, given.Count(a => a is "--seedPassword")); + } +} diff --git a/src/Cli/Tests/Bit.Cli.Tests/HardwareTests.cs b/src/Cli/Tests/Bit.Cli.Tests/HardwareTests.cs index 199e7d47b58..eb41e615cf9 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/HardwareTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/HardwareTests.cs @@ -16,21 +16,24 @@ public void TurnedOffVirtualization_Should_BeReportedOnlyWhenSomethingNeedsIt() var off = new HardwareFacts { VirtualizationEnabled = false }; var android = new HashSet { Platform.Web, Platform.Android }; - var dockerDesktop = Hardware.Evaluate(off, new ToolNeeds { Aspire = true }, HostOs.Windows).Single(); - Assert.AreEqual("virtualization", dockerDesktop.Id); - StringAssert.Contains(dockerDesktop.Text, "Docker Desktop needs it"); - Assert.AreEqual(Hardware.VirtualizationVideo, dockerDesktop.Link); + var containers = Hardware.Evaluate(off, new ToolNeeds { Aspire = true, ContainersRequired = true }, HostOs.Windows).Single(); + Assert.AreEqual("virtualization", containers.Id); + StringAssert.Contains(containers.Text, "the containers Aspire runs need it"); + Assert.AreEqual(Hardware.VirtualizationVideo, containers.Link); - StringAssert.Contains(Hardware.Evaluate(off, new ToolNeeds { Aspire = true, Platforms = android }, HostOs.MacOS).Single().Text, "Docker Desktop and the Android emulator"); + StringAssert.Contains(Hardware.Evaluate(off, new ToolNeeds { Aspire = true, ContainersRequired = true, Platforms = android }, HostOs.MacOS).Single().Text, "the containers Aspire runs and the Android emulator"); StringAssert.Contains(Hardware.Evaluate(off, new ToolNeeds { Platforms = android }, HostOs.Linux).Single().Text, "KVM"); Assert.IsEmpty(Hardware.Evaluate(off, new ToolNeeds { Aspire = false }, HostOs.Windows)); - Assert.IsEmpty(Hardware.Evaluate(off, new ToolNeeds { Aspire = true }, HostOs.Linux)); + Assert.IsEmpty(Hardware.Evaluate(off, new ToolNeeds { Aspire = true }, HostOs.Windows), "A project that runs without its containers gets no virtualization warning."); + Assert.IsEmpty(Hardware.Evaluate(off, new ToolNeeds { Aspire = true, ContainersRequired = true }, HostOs.Linux)); Assert.IsEmpty(Hardware.Evaluate(new HardwareFacts(), new ToolNeeds { Aspire = true, Platforms = android }, HostOs.Windows)); } [TestMethod] - [DataRow(15.8, true, "16 GB")] + [DataRow(7.8, true, "8 GB")] + [DataRow(13.9, true, "14 GB")] + [DataRow(15.8, false, null)] [DataRow(23.4, false, null)] [DataRow(31.9, false, null)] public void LittleMemory_Should_BeReported(double gigabytes, bool reported, string? text) @@ -42,7 +45,7 @@ public void LittleMemory_Should_BeReported(double gigabytes, bool reported, stri if (text is not null) { StringAssert.Contains(warnings.Single().Text, text); - StringAssert.Contains(warnings.Single().Text, "24 GB"); + StringAssert.Contains(warnings.Single().Text, "16 GB"); } } diff --git a/src/Cli/Tests/Bit.Cli.Tests/Infrastructure/FakeProcessRunner.cs b/src/Cli/Tests/Bit.Cli.Tests/Infrastructure/FakeProcessRunner.cs index 66b75f6dbd3..81fb610122d 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/Infrastructure/FakeProcessRunner.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/Infrastructure/FakeProcessRunner.cs @@ -4,7 +4,7 @@ namespace Bit.Cli.Tests.Infrastructure; public sealed class FakeProcessRunner : IProcessRunner { - private readonly List<(Func Match, Func Result)> rules = []; + private readonly List<(Func Match, Func> Result)> rules = []; public Dictionary Executables { get; } = new(StringComparer.OrdinalIgnoreCase); @@ -15,6 +15,11 @@ public sealed class FakeProcessRunner : IProcessRunner public ProcessResult Default { get; set; } = new() { ExitCode = 0 }; public FakeProcessRunner On(string fileName, string argumentsStartWith, Func respond) + { + return OnAsync(fileName, argumentsStartWith, (spec, _) => Task.FromResult(respond(spec))); + } + + public FakeProcessRunner OnAsync(string fileName, string argumentsStartWith, Func> respond) { rules.Add((spec => string.Equals(Path.GetFileNameWithoutExtension(spec.FileName), fileName, StringComparison.OrdinalIgnoreCase) && string.Join(' ', spec.Arguments).StartsWith(argumentsStartWith, StringComparison.Ordinal), respond)); @@ -43,7 +48,7 @@ public Task RunAsync(ProcessSpec spec, CancellationToken cancella foreach (var (match, result) in rules.AsEnumerable().Reverse()) { if (match(spec)) - return Task.FromResult(result(spec)); + return result(spec, cancellationToken); } return Task.FromResult(Default); diff --git a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs index 019068cee93..f0fea638ef8 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs @@ -319,6 +319,7 @@ public async Task AspireStart_Should_StartNothingWhenAspireCantUseDocker() host.Runner.On("aspire", "doctor", 1, """{"checks":[{"category":"sdk","name":"dotnet-sdk","status":"pass"},{"category":"container","name":"docker","status":"fail","message":"Docker is running in Windows container mode","fix":"Switch Docker Desktop to Linux containers mode"}]}"""); var project = CreateFakeProject(host, "Contoso"); WriteAppHost(project, "serverweb"); + NeedContainers(project); var result = await new ProjectSteps(host.Services, project).AspireStartAsync(_ => { }, CancellationToken.None); @@ -388,14 +389,114 @@ public async Task AspireStart_Should_StartNothingWithoutDocker() host.Runner.On("docker", "info", 1, "Cannot connect to the Docker daemon"); var project = CreateFakeProject(host, "Contoso"); WriteAppHost(project, "serverweb"); + NeedContainers(project); - var result = await new ProjectSteps(host.Services, project).AspireStartAsync(_ => { }, CancellationToken.None); + var result = await new ProjectSteps(host.Services, project).AspireStartAsync(keepRunning: true, _ => { }, CancellationToken.None); Assert.AreEqual(StepStatus.Warning, result.Status); Assert.AreEqual("Docker isn't running", result.Detail); Assert.IsFalse(host.Runner.Calls.Any(c => c.FileName is "aspire" && c.Arguments.FirstOrDefault() is "start")); } + [TestMethod] + public async Task AspireStart_Should_RunWithoutDockerWhenTheProjectDoesntWaitForContainers() + { + using var host = new TestHost(HostOs.Linux); + host.Runner.Executables["aspire"] = "aspire"; + host.Runner.On("docker", "info", 1, "Cannot connect to the Docker daemon"); + host.Runner.On("aspire", "start", 0, """{ "appHostPath": "AppHost.csproj", "appHostPid": 1, "cliPid": 2, "dashboardUrl": "https://localhost:2030/login?t=abc", "logFile": "start.log" }"""); + host.Runner.On("aspire", "describe serverweb", 0, """{"resources":[{"name":"serverweb","urls":[{"name":"http","url":"http://localhost:5030","isInternal":false}]}]}"""); + var project = CreateFakeProject(host, "Contoso"); + WriteAppHost(project, "serverweb"); + var steps = new ProjectSteps(host.Services, project); + + Assert.AreEqual(StepStatus.Skipped, (await steps.AspireStartAsync(_ => { }, CancellationToken.None)).Status, "Without containers, a first start has nothing to get ready."); + + var result = await steps.AspireStartAsync(keepRunning: true, _ => { }, CancellationToken.None); + + Assert.AreEqual(StepStatus.Succeeded, result.Status, result.Detail); + Assert.AreEqual("without its containers", result.Detail); + Assert.AreEqual("https://localhost:2030/login?t=abc", steps.Running!.DashboardUrl); + Assert.AreEqual("http://localhost:5030", steps.Running.AppUrl); + Assert.IsFalse(host.Runner.Calls.Any(c => c.Arguments.FirstOrDefault() is "stop"), "A project that keeps running isn't stopped."); + + await steps.Running.StopAsync(true); + CollectionAssert.AreEqual(new[] { "stop", "--apphost", project.AppHostDirectory, "--non-interactive" }, host.Runner.Detached.Single().Arguments.ToArray()); + } + + [TestMethod] + public async Task DotnetRun_Should_StartServerWebAndWaitUntilItIsAlive() + { + using var host = new TestHost(HostOs.Linux); + host.Runner.OnAsync("dotnet", "run", async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return new ProcessResult { ExitCode = 0 }; + }); + var project = CreateFakeProject(host, "Contoso"); + WriteServerWeb(project, "http://localhost:5030;http://*:5030"); + var probes = new List(); + var steps = new ProjectSteps(host.Services, project) { AliveProbe = (url, _) => { probes.Add(url); return Task.FromResult(true); } }; + + var result = await steps.DotnetRunAsync(_ => { }, CancellationToken.None); + + Assert.AreEqual(StepStatus.Succeeded, result.Status, result.Detail); + Assert.AreEqual("http://localhost:5030", steps.Running!.AppUrl); + Assert.AreEqual(new Uri("http://localhost:5030/alive"), probes.Single()); + CollectionAssert.Contains(host.Runner.Calls.Single(c => c.FileName is "dotnet").Arguments.ToArray(), "--no-build"); + + await steps.Running.StopAsync(false); + Assert.IsTrue(steps.Running.Exited!.IsCompleted, "Stopping it ends dotnet run."); + } + + [TestMethod] + public async Task DotnetRun_Should_ReportAServerThatExits() + { + using var host = new TestHost(HostOs.Linux); + host.Runner.On("dotnet", "run", 1, "Unhandled exception. System.InvalidOperationException: No database"); + var project = CreateFakeProject(host, "Contoso"); + WriteServerWeb(project, "http://localhost:5030"); + var steps = new ProjectSteps(host.Services, project) { AliveProbe = (_, _) => Task.FromResult(false) }; + + var result = await steps.DotnetRunAsync(_ => { }, CancellationToken.None); + + Assert.AreEqual(StepStatus.Warning, result.Status); + Assert.IsNull(steps.Running); + } + + [TestMethod] + public void TheProject_Should_KeepRunningOnlyForSomeoneAtTheTerminal() + { + using var redirected = new TestHost(HostOs.Linux); + Assert.AreEqual(ProjectRunMode.Once, ProjectRun.Mode(redirected.Services, aspire: true, noBuild: false, noRun: false)); + Assert.AreEqual(ProjectRunMode.None, ProjectRun.Mode(redirected.Services, aspire: false, noBuild: false, noRun: false)); + + using var terminal = new TestHost(HostOs.Linux, prompter: new ScriptedPrompter()); + Assert.AreEqual(ProjectRunMode.UntilClosed, ProjectRun.Mode(terminal.Services, aspire: true, noBuild: false, noRun: false)); + Assert.AreEqual(ProjectRunMode.UntilClosed, ProjectRun.Mode(terminal.Services, aspire: false, noBuild: false, noRun: false)); + Assert.AreEqual(ProjectRunMode.None, ProjectRun.Mode(terminal.Services, aspire: true, noBuild: false, noRun: true)); + Assert.AreEqual(ProjectRunMode.None, ProjectRun.Mode(terminal.Services, aspire: true, noBuild: true, noRun: false)); + + using var agent = new TestHost(HostOs.Linux, new Dictionary { ["CLAUDECODE"] = "1" }, new ScriptedPrompter()); + Assert.AreEqual(ProjectRunMode.Once, ProjectRun.Mode(agent.Services, aspire: true, noBuild: false, noRun: false), "A coding agent's shell would wait for ever."); + + using var ci = new TestHost(HostOs.Linux, new Dictionary { ["CI"] = "true" }, new ScriptedPrompter()); + Assert.AreEqual(ProjectRunMode.None, ProjectRun.Mode(ci.Services, aspire: true, noBuild: false, noRun: false)); + } + + [TestMethod] + public void TheAppHost_Should_TellWhetherTheProjectWaitsForContainers() + { + using var host = new TestHost(); + var project = CreateFakeProject(host, "Contoso"); + WriteAppHost(project, "serverweb"); + + Assert.IsFalse(ProjectContext.AppHostNeedsContainers(project.AppHostDirectory)); + + NeedContainers(project); + Assert.IsTrue(ProjectContext.AppHostNeedsContainers(project.AppHostDirectory)); + } + [TestMethod] public async Task AspireStart_Should_StopAProjectThatDoesntGetHealthy() { @@ -758,6 +859,28 @@ private static void WriteAppHost(ProjectContext project, params string[] resourc File.WriteAllLines(Path.Combine(folder, "Program.cs"), resources.Select(r => $"builder.AddProject(\"{r}\", \"../{r}.csproj\");")); } + private static void NeedContainers(ProjectContext project) + { + File.AppendAllText(Path.Combine(project.AppHostDirectory, "Program.cs"), "var postgres = builder.AddPostgres(\"postgres\");\n"); + } + + private static void WriteServerWeb(ProjectContext project, string urls) + { + var web = Directory.CreateDirectory(Path.Combine(project.Directory, "src", "Server", $"{project.Name}.Server.Web")).FullName; + var properties = Directory.CreateDirectory(Path.Combine(web, "Properties")).FullName; + + File.WriteAllText(Path.Combine(web, $"{project.Name}.Server.Web.csproj"), $""); + File.WriteAllText(Path.Combine(properties, "launchSettings.json"), $$""" + { + // the app's own + "profiles": { + "IIS Express": { "commandName": "IISExpress" }, + "Web": { "commandName": "Project", "applicationUrl": "{{urls}}" }, + } + } + """); + } + private static void WriteRecommendations(ProjectContext project, params string[] extensions) { var folder = Directory.CreateDirectory(Path.Combine(project.Directory, ".vscode")).FullName; diff --git a/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs index 8ea88aca7ed..b546d434b67 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs @@ -87,6 +87,49 @@ public async Task Docker_Should_TellNotRunningFromMissing() Assert.AreEqual(ToolState.Missing, missing.Status.State); } + [TestMethod] + public async Task Podman_Should_CountAsTheContainerRuntime() + { + using var host = new TestHost(HostOs.Windows); + AllInstalled(host.Runner); + host.Runner.NotFound("docker"); + host.Runner.Executables["podman"] = @"C:\Podman\podman.exe"; + host.Runner.On("podman", "--version", 0, "podman version 6.1.4"); + + var running = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker"); + Assert.AreEqual(ToolState.Installed, running.Status.State); + Assert.AreEqual("Podman 6.1.4", running.Status.Version); + + host.Runner.On("podman", "container ls", 125, "Cannot connect to Podman"); + host.Runner.On("podman", "machine inspect", 125, "VM does not exist"); + var withoutMachine = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker"); + Assert.AreEqual(ToolState.NotRunning, withoutMachine.Status.State); + Assert.AreEqual("Start Podman", withoutMachine.Action!.Title); + CollectionAssert.AreEqual(new[] { "machine", "init", "--now" }, withoutMachine.Action.Commands[0].Arguments.ToArray()); + + host.Runner.On("podman", "machine inspect", 0, "[{}]"); + var stopped = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker"); + CollectionAssert.AreEqual(new[] { "machine", "start" }, stopped.Action!.Commands[0].Arguments.ToArray()); + } + + [TestMethod] + public async Task MacOS_Should_GetColimaAndTheDockerCli() + { + using var host = new TestHost(HostOs.MacOS); + AllInstalled(host.Runner); + host.Runner.Executables["brew"] = "/opt/homebrew/bin/brew"; + host.Runner.NotFound("docker"); + + var install = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker").Action!; + Assert.AreEqual("Install Colima and Docker", install.Title); + CollectionAssert.AreEqual(new[] { "install", "colima", "docker", "docker-buildx" }, install.Commands[0].Arguments.ToArray()); + StringAssert.Contains(install.Commands[^1].Arguments[1], "services start colima"); + + host.Runner.On("docker", "--version", 0, "Docker version 28.5.1"); + host.Runner.Executables["colima"] = "/opt/homebrew/bin/colima"; + Assert.AreEqual("Start Colima", (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker").Action!.Title); + } + [TestMethod] public async Task DockerAndTheAspireCli_Should_BeNeededOnlyWithAspire() { @@ -404,9 +447,14 @@ public async Task WindowsInstalls_Should_UseWinget() Assert.AreEqual(Elevation.Admin, node.Elevation); CollectionAssert.IsSubsetOf(new[] { "install", "--id", "OpenJS.NodeJS.LTS", "--exact" }, node.Commands[0].Arguments.ToArray()); var docker = checks.Single(c => c.Tool.Id == "docker").Action!; - Assert.AreEqual("Install Docker Desktop", docker.Title); - CollectionAssert.IsSubsetOf(new[] { "Docker.DockerDesktop", "--override", "install --quiet --accept-license" }, docker.Commands[0].Arguments.ToArray()); - CollectionAssert.DoesNotContain(docker.Commands[0].Arguments.ToArray(), "--silent"); + Assert.AreEqual("Install Podman", docker.Title); + Assert.AreEqual(Elevation.None, docker.Elevation, "Podman installs for the user, so it needs no administrator prompt of its own."); + CollectionAssert.IsSubsetOf(new[] { "install", "--id", "Podman.CLI", "--silent" }, docker.Commands[0].Arguments.ToArray()); + CollectionAssert.AreEqual(new[] { "machine", "init", "--now" }, docker.Commands[1].Arguments.ToArray()); + StringAssert.EndsWith(docker.Commands[1].FileName, @"Programs\Podman\podman.exe"); + CollectionAssert.Contains(docker.Commands[2].Arguments.ToArray(), @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"); + Assert.IsTrue(docker.Optional, "A project that runs without its containers doesn't fail on a missing container runtime."); + Assert.IsFalse((await CheckAsync(host, new ToolNeeds { Aspire = true, ContainersRequired = true })).Single(c => c.Tool.Id == "docker").Action!.Optional); host.Runner.On("dotnet", "--version", 145); host.Runner.Executables["dotnet"] = @"C:\Program Files\dotnet\dotnet.exe"; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.agents/skills/upgrade/SKILL.md b/src/Templates/Boilerplate/Bit.Boilerplate/.agents/skills/upgrade/SKILL.md new file mode 100644 index 00000000000..cbd631c319c --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.agents/skills/upgrade/SKILL.md @@ -0,0 +1,6 @@ +--- +name: upgrade +description: Brings a newer bit Boilerplate release into this project. `bit diff` creates the project with the version it came from and with the newer one, and the skill carries every upstream change that still makes sense into this codebase while keeping the team's own work, adds the database migrations the changes need, updates the version the project records, and proves the result builds, passes its tests and runs. Use when the user asks to upgrade or update the project to a newer bit Boilerplate or bit platform version, to bring in the template's latest changes, or says "run upgrade". +--- + +Read `.github/agents/upgrade.agent.md` (relative to the repository root) and follow it exactly. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.claude/skills/upgrade/SKILL.md b/src/Templates/Boilerplate/Bit.Boilerplate/.claude/skills/upgrade/SKILL.md new file mode 100644 index 00000000000..cbd631c319c --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.claude/skills/upgrade/SKILL.md @@ -0,0 +1,6 @@ +--- +name: upgrade +description: Brings a newer bit Boilerplate release into this project. `bit diff` creates the project with the version it came from and with the newer one, and the skill carries every upstream change that still makes sense into this codebase while keeping the team's own work, adds the database migrations the changes need, updates the version the project records, and proves the result builds, passes its tests and runs. Use when the user asks to upgrade or update the project to a newer bit Boilerplate or bit platform version, to bring in the template's latest changes, or says "run upgrade". +--- + +Read `.github/agents/upgrade.agent.md` (relative to the repository root) and follow it exactly. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/07- ASP.NET Core Identity - Authentication & Authorization.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/07- ASP.NET Core Identity - Authentication & Authorization.md index 689b1fcabea..bb706f8e0f6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/07- ASP.NET Core Identity - Authentication & Authorization.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/07- ASP.NET Core Identity - Authentication & Authorization.md @@ -352,8 +352,8 @@ a container, pre-loaded from | Username | Password | Group | |---|---|---| -| test | 123456 | `g-admin` | -| store-admin | 123456 | `t-admin`, used when multi-tenancy is enabled | +| test | 12345678 | `g-admin` | +| store-admin | 12345678 | `t-admin`, used when multi-tenancy is enabled | | bob | bob | `demo` | | alice | alice | `demo` | diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/15- Logging, OpenTelemetry and Health Checks.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/15- Logging, OpenTelemetry and Health Checks.md index ab4e55a097a..1ae0cf02bfd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/15- Logging, OpenTelemetry and Health Checks.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/15- Logging, OpenTelemetry and Health Checks.md @@ -303,8 +303,8 @@ The project includes **health check endpoints** to monitor application health. 2. **`/alive`** - liveness. Runs only the checks tagged `"live"`, which today is the disk-space check alone. 3. **`/healthz`** - the detailed report behind the **Operations** page (Management menu). It keeps the HealthChecks UI format and adds each check's failure status, timeout and full exception, plus the time of the report. It needs the - `AppFeatures.System.Operations_View` feature, which only global admins have, and it always answers 200: the status is - in the body. + `AppFeatures.System.Operations_View` feature, which only global admins have. Like `/health`, it answers 503 when a + check reports Unhealthy, so a monitor can go by the status code, and the report is in the body either way. `/health` and `/alive` are mapped in **every** environment and are **anonymous**, and they answer with one word. `/healthz`, which names every dependency and carries each failure's exception (possibly a connection string), is diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/17- Automated Testing (Unitigration Tests).md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/17- Automated Testing (Unitigration Tests).md index 7de8ced0a65..2dd4e3ada87 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/17- Automated Testing (Unitigration Tests).md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/17- Automated Testing (Unitigration Tests).md @@ -223,7 +223,7 @@ var authManager = client.AuthManager; ### TestData Constants ```csharp public const string DefaultTestEmail = "test@bitplatform.dev"; -public const string DefaultTestPassword = "123456"; +public const string DefaultTestPassword = "12345678"; ``` - Centralized test data in [`TestData.cs`](/src/Tests/Features/Identity/TestData.cs) - Seeds are created in `AppDbContext.OnModelCreating()` for development/testing diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md index 15727d886a6..63e92e8bfc9 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md @@ -174,6 +174,26 @@ Everything lives in the one skill file; it brings no scripts or tools of its own --- +### 7. Upgrade (`upgrade`) + +**Canonical file**: `.github/agents/upgrade.agent.md` + +**What it does**: Brings a newer bit Boilerplate version into the project. `bit diff` creates the project with the version it came from and with the newer one into one git repository, so the second commit is exactly what the template changed for a project with this name and these options. The skill carries each of those changes into the codebase, keeping what the team changed, adds the database migrations the changes need, and updates the version `README.md` and the MCP server configurations record. + +**When to use it**: When a newer bit Boilerplate is out and the project should get its fixes, features and dependency updates. + +**Key capabilities**: +- Reads the project's version and options from `README.md`, and checks them against the code before diffing +- Treats the old version as the common ancestor: takes files the team never changed, merges files both changed, and leaves out what no longer has anything to attach to +- Knows which differences are artifacts of generating a project (ports, GUIDs, dates, the app certificate) rather than upgrades +- Adds a migration for every database context whose model changed, instead of editing existing ones +- Asks only about decisions that change how the app behaves +- Proves the result: build, formatting, tests, and the app running + +**Example usage**: `/upgrade` or `/upgrade to the newest prerelease` + +--- + ### AI Wiki Ask your own question [here](https://bitplatform.dev/ask) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md index 4ce0797b739..23c18acdfbf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md @@ -713,7 +713,7 @@ The `bit` command stays available in its terminal, for `bit doctor` and `bit tra - Isolated from your host system **How to use**: -1. Install Docker Desktop and the "Dev Containers" extension in VS Code +1. Install Docker (or Podman, with VS Code's `dev.containers.dockerPath` set to `podman`) and the "Dev Containers" extension in VS Code 2. Open the project in VS Code 3. Click "Reopen in Container" when prompted 4. Wait for the container to build and for `bit setup` to finish; GitHub Codespaces prebuilds do both ahead of time diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/20- .NET Aspire.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/20- .NET Aspire.md index c57c92e8c91..c43dc2cda5b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/20- .NET Aspire.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/20- .NET Aspire.md @@ -186,7 +186,7 @@ resource's connection string in the dashboard rather than assuming `1433`. `builder.UsePersistentContainers()` at the bottom of `Program.cs` in the `Boilerplate.Server.AppHost` project keeps every container (database, Keycloak, Redis, MailPit, storage and their management UIs) alive between runs and reuses them, instead of booting them up from scratch each time. Starting the project with `F5` / `aspire start` and running the automated tests become considerably faster. -Once the app host hasn't run for 15 minutes, the containers are stopped, so they don't keep consuming memory after you've moved on to something else; the next run boots them up again. Pass another value to change that, e.g. `builder.UsePersistentContainers(idleMinutes: 60)`. To free the memory right away, stop them from **Docker Desktop** (or with `docker stop`). +Once the app host hasn't run for 15 minutes, the containers are stopped, so they don't keep consuming memory after you've moved on to something else; the next run boots them up again. Pass another value to change that, e.g. `builder.UsePersistentContainers(idleMinutes: 60)`. To free the memory right away, stop them with `docker stop` (or `podman stop`). Under the hood, the first run starts a tiny background process outside the app host's process tree. Every running app host keeps it alive and tells it which containers it uses, and once no app host has run for 15 minutes, it stops them and exits. If that process can't be started, the containers fall back to Aspire's default: created on each run and removed when the app host stops. @@ -194,6 +194,14 @@ Your **data** is not lost with the containers - the resources that need it are c --- +## Without Docker or Podman + +The projects run without a container runtime too. When none is running, `builder.RunWithoutContainerRuntime()` drops the containers' volumes, which Aspire would otherwise wait for until it ends the whole run, so `Server.Web` starts as usual and the dashboard shows the containers as unhealthy. With SQLite that is a working app, only without Keycloak and the MailPit inbox. A SQL Server, PostgreSQL or MySQL database and Redis are different: the projects wait for them, so those need Docker or Podman. + +`bit setup` installs one that is free for commercial use: Podman on Windows, Colima with the docker CLI on macOS, and Docker Engine on Linux. Aspire finds Docker or Podman on its own; set `ASPIRE_CONTAINER_RUNTIME` to pick one when both are installed. + +--- + ## Important Note: Docker Execution ### How This Project Uses Docker @@ -213,7 +221,7 @@ Your **data** is not lost with the containers - the resources that need it are c If you want to **test server behavior in Linux** while **still using Aspire** on Windows: 1. Open the project in **Visual Studio Code** -2. Use **Dev Containers** feature (requires Docker Desktop and Dev Containers extension) +2. Use **Dev Containers** feature (requires Docker or Podman, and the Dev Containers extension) 3. Run this command in the terminal to clean previous Windows build artifacts: ```bash chmod +x ./Clean.sh && ./Clean.sh diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.gemini/commands/upgrade.toml b/src/Templates/Boilerplate/Bit.Boilerplate/.gemini/commands/upgrade.toml new file mode 100644 index 00000000000..2e7b2dd1447 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.gemini/commands/upgrade.toml @@ -0,0 +1,9 @@ +description = "Upgrade the project to a newer bit Boilerplate version: bit diff the old and new template, bring every change that still applies into this codebase, add the migrations it needs, update the recorded version, and prove it builds, passes its tests and runs." + +prompt = """ +Follow these instructions exactly: + +@{.github/agents/upgrade.agent.md} + +Target version and notes: {{args}} +""" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md new file mode 100644 index 00000000000..71502a8ae53 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md @@ -0,0 +1,65 @@ +--- +name: upgrade +description: Brings a newer bit Boilerplate release into this project. `bit diff` creates the project with the version it came from and with the newer one, and the skill carries every upstream change that still makes sense into this codebase while keeping the team's own work, adds the database migrations the changes need, updates the version the project records, and proves the result builds, passes its tests and runs. +--- + +# Upgrade to a newer bit Boilerplate + +This project was created from the bit Boilerplate template, and the team has changed it since. A newer template version fixes bugs, adds features and moves dependencies forward. The goal is to end up with everything the newer version brings that still applies here, and to lose none of the team's own work. + +The work is yours to judge. Ask the user only what the code can't answer and what changes how the app behaves, such as a new default the team may not want or an upstream change to a feature the team removed. Don't ask about mechanics. + +## 1. Know where the project stands + +- `README.md` records the bit Boilerplate version the project comes from and the `bit new` command, with every option, that created it. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. When they disagree, the project may have been upgraded before without the README; the package versions of the bit platform libraries in `src/Directory.Packages.props` tell which one is right. +- Check the recorded options against the code. The team may have turned something off after creation, switched the database provider, or removed a module, and the diff should be made with the options the project has today. +- The target is the version the user names. Without one, it's the newest stable bit Boilerplate; take a prerelease only when the user asks. The release notes on GitHub for every version in between say why things changed, and the bit platform MCP server answers questions about any of them. +- Start from a clean working tree on a new branch, so the whole upgrade can be reviewed as one change. If there are uncommitted changes, ask the user whether to commit or stash them first. +- The bit CLI must know the target version: update it, or run it through `dnx` at that version. + +## 2. Get the upstream changes + +- Run `bit diff` in the project folder, with the from and to versions when they aren't the defaults and with any option whose recorded value no longer matches the project. It creates the project with each version into one git repository, so its second commit is exactly what the template changed for a project with this name and these options, and it writes that commit as a patch file. Its output says where both are. +- Both versions are created with this project's name and its seeded accounts' password, so those don't show up as changes. Some differences are still not upgrades and stay as they are in the project: the ports, GUIDs and dates the template picks on every run, the app certificate, the formatting `bit new` applies, and EF Core migrations, which neither version has. +- Read the whole change set before touching anything: which areas it touches, which files are new, deleted or renamed, and which changes belong together, such as a package bump and the code that needs it. + +## 3. Bring each change in + +Treat the version the project came from as the common ancestor of the newer version and this project: + +- A file the team never changed takes the newer version as it is, apart from the generation differences above. +- A file both changed gets both: the team's change stays and upstream's is added. A three-way merge with the old version as the base does most of it; finish by hand where the two meet. +- A file upstream deleted goes, unless the team changed it; then decide from what upstream replaced it with, or ask. +- A new file comes in, unless it only serves a feature this project removed. +- A part the team rewrote or removed so thoroughly that upstream's change has nothing to attach to is left out. Say so in the report, with why. + +Some changes need more than the text of the diff: + +- **Packages and SDKs.** Take the newer versions in `src/Directory.Packages.props`, `global.json` and the `package.json` files, keep the team's own additions, then restore. A package the team pinned on purpose stays pinned; mention it. +- **Database.** When entities, their configurations or the seed data changed, add a new migration for every database context whose model changed, the way `AGENTS.md` describes. Never edit a migration the project already has. +- **Settings.** New `appsettings` keys and new user secrets come in with their defaults; list the ones that need a real value for production. +- **Generated code.** Regenerate what the build or a tool generates instead of merging it. +- **Naming.** Upstream text arrives with this project's name already in it; keep the team's own names, branding and copy wherever they replaced the template's. + +## 4. Update the version the project records + +- In `README.md`, the version in the line that says which bit Boilerplate generated the project, so the next upgrade starts from here. Bring in the rest of the README's upstream changes like any other file. +- The version in the bit platform MCP server URL in every MCP configuration listed above, and in any other agent configuration that pins it. +- Search the project for the old version string and update every place that names the template or bit platform version, such as the package versions of the bit platform libraries. Leave other packages that happen to share the number alone. + +## 5. Prove it + +- No conflict markers, no leftover patch rejects or originals, and nothing of the team's own that the diff doesn't touch has changed. +- The whole solution builds without new warnings, and the code passes the project's formatting check. +- Every test passes: unit, integration and, where they can run, UI tests. A test that fails because upstream changed behavior on purpose is updated to the new behavior; any other failure is a bug in the upgrade. +- The app starts. It signs in with a seeded account, the pages the changes touched work, and with Aspire the dashboard shows every resource the project uses as healthy. + +## 6. Report + +Tell the user, briefly: + +- from which version to which, and on which branch; +- what came in, grouped by area, with the user-visible changes first; +- what was left out and why; +- the decisions that are theirs, and the migrations, settings and secrets that need their attention; +- that nothing is merged or pushed until they say so. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json index a9cadfdd819..cd5f0e11614 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json @@ -79,6 +79,10 @@ { "id": "advancedTests", "isVisible": false + }, + { + "id": "seedPassword", + "isVisible": false } ] } \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index 39f1b43237e..512c3143103 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -40,6 +40,29 @@ "format": "yyyy-MM-dd" } }, + "seedPassword": { + "displayName": "Seeded accounts' password", + "type": "parameter", + "datatype": "string", + "description": "The password of test@bitplatform.dev and the other accounts the database starts with. Default: a random 8 digit number." + }, + "seedPasswordRandom": { + "type": "generated", + "generator": "random", + "parameters": { + "low": 10000000, + "high": 99999999 + } + }, + "seedPasswordValue": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "seedPassword", + "fallbackVariableName": "seedPasswordRandom" + }, + "replaces": "12345678" + }, "database": { "displayName": "Backend database", "type": "parameter", diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/AGENTS.md b/src/Templates/Boilerplate/Bit.Boilerplate/AGENTS.md index a07224f5c11..e8a8d2fb549 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/AGENTS.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/AGENTS.md @@ -138,5 +138,6 @@ This project ships reusable [Agent Skills](https://agentskills.io), canonical in | `review` | Reviewing a diff or PR against this project's conventions | `.github/agents/review.agent.md` | | `ai-dlc` | Driving a feature end-to-end through requirements, plan, design, implement, validate | `.github/agents/ai-dlc.agent.md` | | `rebrand` | Re-skinning the whole app to match a design reference (a live site, Figma, images, brand guidelines, tokens or a described mood) | `.github/agents/rebrand.agent.md` | +| `upgrade` | Upgrading the project to a newer bit Boilerplate version | `.github/agents/upgrade.agent.md` | Tools that discover Agent Skills natively (GitHub Copilot in VS Code / Visual Studio / github.com / CLI, Cursor, Codex, Junie, Windsurf, Antigravity, Gemini CLI) can invoke each one as a `/` slash command, and Visual Studio lists the canonical files themselves in its agent picker as `@`. The files under `.agents/skills/`, `.claude/skills/` and `.gemini/commands/` are thin bridges that delegate to the canonical file - never edit them for content, only the canonical file above. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln index 835e2adc89d..b9c17a0da5b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln +++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln @@ -57,6 +57,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "agents", "agents", "{4A5D6E .github\agents\rebrand.agent.md = .github\agents\rebrand.agent.md .github\agents\review.agent.md = .github\agents\review.agent.md .github\agents\scaffold-entity.agent.md = .github\agents\scaffold-entity.agent.md + .github\agents\upgrade.agent.md = .github\agents\upgrade.agent.md EndProjectSection EndProject #if (pipeline == "GitHub") diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx index 8f8327a7fab..8d0e43dff38 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx @@ -34,6 +34,7 @@ + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/README.md b/src/Templates/Boilerplate/Bit.Boilerplate/README.md index fd7bce60d90..5e807fae222 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/README.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/README.md @@ -92,14 +92,14 @@ This project is generated by bit Boilerplate v-10.7.0-pre-02 using the following So that the app is usable and testable the moment it is generated, the database seed (`src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs`) creates real, -pre-confirmed accounts whose password is **`123456`**: +pre-confirmed accounts whose password is **`12345678`**: | E-mail | Password | Role | |---|---|---| -| `test@bitplatform.dev` | `123456` | `g-admin` - **global administrator, holds every feature** | +| `test@bitplatform.dev` | `12345678` | `g-admin` - **global administrator, holds every feature** | -| `store-admin@bitplatform.dev` | `123456` | `t-admin` of the seeded `store` tenant | -| `store-user@bitplatform.dev` | `123456` | `demo` member of the seeded `store` tenant | +| `store-admin@bitplatform.dev` | `12345678` | `t-admin` of the seeded `store` tenant | +| `store-user@bitplatform.dev` | `12345678` | `demo` member of the seeded `store` tenant | These credentials, their password hash and their primary keys are all part of the public bit platform template, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs index df1b39acdb3..31d3d2447c5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs @@ -1,4 +1,5 @@ //+:cnd:noEmit +using System.Net; using System.Text; using Boilerplate.Shared.Features.Diagnostic; @@ -191,8 +192,15 @@ private async Task LoadDeploymentConfiguration() } } - private Task GetReport(string url) => - httpClient.GetFromJsonAsync(url, JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken); + private async Task GetReport(string url) + { + using HttpRequestMessage request = new(HttpMethod.Get, url); + request.Options.Set(new HttpRequestOptionsKey(RequestOptionNames.AllowedStatusCodes), [HttpStatusCode.ServiceUnavailable]); + + using var response = await httpClient.SendAsync(request, CurrentCancellationToken); + + return await response.Content.ReadFromJsonAsync(JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken); + } //#if (api == "Standalone") /// diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs index ed8cc83fd58..b3a9c7b4434 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs @@ -35,6 +35,12 @@ protected override async Task SendAsync(HttpRequestMessage serverCommunicationSuccess = true; + if (request.Options.TryGetValue(new HttpRequestOptionsKey(RequestOptionNames.AllowedStatusCodes), out var allowedStatusCodes) + && allowedStatusCodes.Contains(response.StatusCode)) + { + return response; + } + if (isInternalRequest && /* The following exception handling mechanism applies exclusively to responses from our own server. */ response.IsSuccessStatusCode is false && string.IsNullOrWhiteSpace(requestIdValue) is false && diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/RequestOptionNames.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/RequestOptionNames.cs index d2adbe21f14..561402a2482 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/RequestOptionNames.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/RequestOptionNames.cs @@ -13,4 +13,5 @@ public partial class RequestOptionNames public const string LogScopeData = nameof(LogScopeData); public const string IControllerType = nameof(IControllerType); public const string ActionParametersInfo = nameof(ActionParametersInfo); + public const string AllowedStatusCodes = nameof(AllowedStatusCodes); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs index 0f284146289..6668bd5fcdb 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs @@ -1,5 +1,6 @@ using System.Net.Http.Json; using Boilerplate.Shared.Features.Diagnostic; +using Boilerplate.Client.Core.Infrastructure.Services.HttpMessageHandlers; namespace Boilerplate.Tests.E2E.Features.HealthChecks; @@ -115,7 +116,11 @@ public async Task DetailedReport_Should_ShowEveryCheckHealthyOrNotConfigured(str foreach (var host in hosts) { - var report = await apiClient.HttpClient.GetFromJsonAsync(new Uri(new Uri(host), "healthz"), JsonSerializerOptions.Web, TestContext.CancellationToken); + using HttpRequestMessage request = new(HttpMethod.Get, new Uri(new Uri(host), "healthz")); + request.Options.Set(new HttpRequestOptionsKey(RequestOptionNames.AllowedStatusCodes), [HttpStatusCode.ServiceUnavailable]); + + using var response = await apiClient.HttpClient.SendAsync(request, TestContext.CancellationToken); + var report = await response.Content.ReadFromJsonAsync(JsonSerializerOptions.Web, TestContext.CancellationToken); Assert.IsNotNull(report, $"{host}healthz returned no report."); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs index 8a19f17c430..2086935aba6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs @@ -1,9 +1,30 @@ //+:cnd:noEmit +using System.Buffers.Binary; +using System.Security.Cryptography; namespace Boilerplate.Server.Api.Features.Identity.Configurations; public partial class UserConfiguration : IEntityTypeConfiguration { + private static readonly string seedPasswordHash = HashSeedPassword("12345678"); + + /// Identity's V3 format with a fixed salt, so the model stays the same on every run. + private static string HashSeedPassword(string password) + { + var salt = SHA256.HashData(Encoding.UTF8.GetBytes(password))[..16]; + var subkey = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA512, 32); + var hash = new byte[13 + salt.Length + subkey.Length]; + + hash[0] = 0x01; + BinaryPrimitives.WriteUInt32BigEndian(hash.AsSpan(1), 2); + BinaryPrimitives.WriteUInt32BigEndian(hash.AsSpan(5), 100_000); + BinaryPrimitives.WriteUInt32BigEndian(hash.AsSpan(9), (uint)salt.Length); + salt.CopyTo(hash, 13); + subkey.CopyTo(hash, 13 + salt.Length); + + return Convert.ToBase64String(hash); + } + public void Configure(EntityTypeBuilder builder) { builder.HasMany(user => user.Roles) @@ -42,7 +63,7 @@ public void Configure(EntityTypeBuilder builder) PhoneNumberConfirmed = true, SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", ConcurrencyStamp = "315e1a26-5b3a-4544-8e91-2760cd28e231", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", // 123456 + PasswordHash = seedPasswordHash, }]); //#if (multitenant == true) @@ -65,7 +86,7 @@ public void Configure(EntityTypeBuilder builder) EmailTokenRequestedOn = new DateTimeOffset(new DateOnly(2023, 1, 1), default, default), SecurityStamp = "869ff4a9-4b07-4cc1-8141-c5fc033daf82", ConcurrencyStamp = "425e1a26-5b3a-4544-8e91-2760cd28e230", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", // 123456 + PasswordHash = seedPasswordHash, }]); const string storeUserUserName = "store-user"; @@ -88,7 +109,7 @@ public void Configure(EntityTypeBuilder builder) EmailTokenRequestedOn = new DateTimeOffset(new DateOnly(2023, 1, 1), default, default), SecurityStamp = "469ff4a9-4b07-4cc1-8141-c5fc033daf84", ConcurrencyStamp = "435e1a26-5b3a-4544-8e91-2760cd28e229", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", // 123456 + PasswordHash = seedPasswordHash, }]); //#endif diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs index 84b9b73e06e..e5eda8aa909 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs @@ -405,7 +405,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "TEST@BITPLATFORM.DEV", NormalizedUserName = "TEST", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", PhoneNumber = "+31684207362", PhoneNumberConfirmed = true, SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", @@ -428,7 +428,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-ADMIN@BITPLATFORM.DEV", NormalizedUserName = "STORE-ADMIN", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", PhoneNumberConfirmed = false, SecurityStamp = "869ff4a9-4b07-4cc1-8141-c5fc033daf82", TwoFactorEnabled = false, @@ -450,7 +450,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-USER@BITPLATFORM.DEV", NormalizedUserName = "STORE-USER", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", PhoneNumberConfirmed = false, SecurityStamp = "469ff4a9-4b07-4cc1-8141-c5fc033daf84", TwoFactorEnabled = false, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs index 98abeecd744..791fb16bc65 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs @@ -714,9 +714,9 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Id", "AccessFailedCount", "BirthDate", "ConcurrencyStamp", "CreatedOn", "ElevatedAccessTokenRequestedOn", "Email", "EmailConfirmed", "EmailTokenRequestedOn", "FullName", "Gender", "HasProfilePicture", "LockoutEnabled", "LockoutEnd", "NormalizedEmail", "NormalizedUserName", "OtpRequestedOn", "PasswordHash", "PhoneNumber", "PhoneNumberConfirmed", "PhoneNumberTokenRequestedOn", "ResetPasswordTokenRequestedOn", "SecurityStamp", "TwoFactorEnabled", "TwoFactorTokenRequestedOn", "UserName" }, values: new object[,] { - { new Guid("4ff71671-a1d6-4f97-abb9-d87d7b47d6e4"), 0, 1306790461440000000L, "435e1a26-5b3a-4544-8e91-2760cd28e229", 0L, null, "store-user@bitplatform.dev", true, 1306790461440000000L, "Store tenant user", 0, false, true, null, "STORE-USER@BITPLATFORM.DEV", "STORE-USER", null, "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", null, false, null, null, "469ff4a9-4b07-4cc1-8141-c5fc033daf84", false, null, "store-user" }, - { new Guid("6ff71671-a1d6-4f97-abb9-d87d7b47d6e5"), 0, 1306790461440000000L, "425e1a26-5b3a-4544-8e91-2760cd28e230", 0L, null, "store-admin@bitplatform.dev", true, 1306790461440000000L, "Store tenant admin", 0, false, true, null, "STORE-ADMIN@BITPLATFORM.DEV", "STORE-ADMIN", null, "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", null, false, null, null, "869ff4a9-4b07-4cc1-8141-c5fc033daf82", false, null, "store-admin" }, - { new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), 0, 1306790461440000000L, "315e1a26-5b3a-4544-8e91-2760cd28e231", 0L, null, "test@bitplatform.dev", true, 1306790461440000000L, "Boilerplate test account", 0, false, true, null, "TEST@BITPLATFORM.DEV", "TEST", null, "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", "+31684207362", true, null, null, "959ff4a9-4b07-4cc1-8141-c5fc033daf83", false, null, "test" } + { new Guid("4ff71671-a1d6-4f97-abb9-d87d7b47d6e4"), 0, 1306790461440000000L, "435e1a26-5b3a-4544-8e91-2760cd28e229", 0L, null, "store-user@bitplatform.dev", true, 1306790461440000000L, "Store tenant user", 0, false, true, null, "STORE-USER@BITPLATFORM.DEV", "STORE-USER", null, "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", null, false, null, null, "469ff4a9-4b07-4cc1-8141-c5fc033daf84", false, null, "store-user" }, + { new Guid("6ff71671-a1d6-4f97-abb9-d87d7b47d6e5"), 0, 1306790461440000000L, "425e1a26-5b3a-4544-8e91-2760cd28e230", 0L, null, "store-admin@bitplatform.dev", true, 1306790461440000000L, "Store tenant admin", 0, false, true, null, "STORE-ADMIN@BITPLATFORM.DEV", "STORE-ADMIN", null, "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", null, false, null, null, "869ff4a9-4b07-4cc1-8141-c5fc033daf82", false, null, "store-admin" }, + { new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), 0, 1306790461440000000L, "315e1a26-5b3a-4544-8e91-2760cd28e231", 0L, null, "test@bitplatform.dev", true, 1306790461440000000L, "Boilerplate test account", 0, false, true, null, "TEST@BITPLATFORM.DEV", "TEST", null, "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", "+31684207362", true, null, null, "959ff4a9-4b07-4cc1-8141-c5fc033daf83", false, null, "test" } }); migrationBuilder.InsertData( diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 8e9c1abf3da..4f3afbf7585 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -402,7 +402,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "TEST@BITPLATFORM.DEV", NormalizedUserName = "TEST", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", PhoneNumber = "+31684207362", PhoneNumberConfirmed = true, SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", @@ -425,7 +425,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-ADMIN@BITPLATFORM.DEV", NormalizedUserName = "STORE-ADMIN", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", PhoneNumberConfirmed = false, SecurityStamp = "869ff4a9-4b07-4cc1-8141-c5fc033daf82", TwoFactorEnabled = false, @@ -447,7 +447,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-USER@BITPLATFORM.DEV", NormalizedUserName = "STORE-USER", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", PhoneNumberConfirmed = false, SecurityStamp = "469ff4a9-4b07-4cc1-8141-c5fc033daf84", TwoFactorEnabled = false, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IDistributedApplicationBuilderExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IDistributedApplicationBuilderExtensions.cs index 1636b57a268..947da6433da 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IDistributedApplicationBuilderExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IDistributedApplicationBuilderExtensions.cs @@ -267,6 +267,23 @@ public IDistributedApplicationBuilder RemoveWildcardEndpoints() return builder; } + /// Without a container runtime, a named volume never gets ready and Aspire ends the whole run, so drops them. + public IDistributedApplicationBuilder RunWithoutContainerRuntime() + { + if (builder.ExecutionContext.IsRunMode is false || ContainerRuntime.IsRunning(builder)) + return builder; + + foreach (var container in builder.Resources.OfType()) + { + foreach (var volume in container.Annotations.OfType().Where(mount => mount.Type is ContainerMountType.Volume).ToArray()) + { + container.Annotations.Remove(volume); + } + } + + return builder; + } + /// /// Keeps every container of the application model alive between runs, so that each run reuses them instead of /// booting them up from scratch, and stops them once the app host hasn't run for . @@ -278,7 +295,7 @@ public IDistributedApplicationBuilder RemoveWildcardEndpoints() /// public IDistributedApplicationBuilder UsePersistentContainers(int idleMinutes = 15) { - if (ContainersKeeper.Start(builder, TimeSpan.FromMinutes(idleMinutes)) is false) + if (ContainerRuntime.IsRunning(builder) is false || ContainersKeeper.Start(builder, TimeSpan.FromMinutes(idleMinutes)) is false) return builder; foreach (var container in builder.Resources.OfType().ToArray()) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IResourceBuilderExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IResourceBuilderExtensions.cs index e5d4785ee52..59c0d236d2e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IResourceBuilderExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Extensions/IResourceBuilderExtensions.cs @@ -16,7 +16,7 @@ public static class IResourceBuilderExtensions /// /// The amount of RAM the tuning below assumes the container has (Default: 4096MB). /// It is applied as --memory-reservation, a soft reclaim protection - nothing caps the container at this - /// value, so match it to the memory your container runtime actually grants (Docker Desktop / .wslconfig). + /// value, so match it to the memory your container runtime actually grants (its VM settings / .wslconfig). public IResourceBuilder WithOptimizedSetup(int totalContainerMemoryMB = 4 * 1024) { // SAFETY MARGIN: diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Realms/dev-realm.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Realms/dev-realm.json index ce7bbc1bb86..3360a85a5cf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Realms/dev-realm.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Realms/dev-realm.json @@ -78,7 +78,7 @@ "credentials": [ { "type": "password", - "value": "123456" + "value": "12345678" } ], "disableableCredentialTypes": [], @@ -102,7 +102,7 @@ "credentials": [ { "type": "password", - "value": "123456" + "value": "12345678" } ], "disableableCredentialTypes": [], diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainerRuntime.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainerRuntime.cs new file mode 100644 index 00000000000..f91c2d055f2 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainerRuntime.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using System.ComponentModel; + +namespace Boilerplate.Server.AppHost.Infrastructure.Services; + +/// Picks the container runtime the way Aspire does, as a full path, since the Windows keeper doesn't inherit PATH. +internal static class ContainerRuntime +{ + private static readonly ConcurrentDictionary found = []; + + public static string Find(IDistributedApplicationBuilder builder) => Resolve(builder).Path; + + /// False only when the runtime is surely missing or stopped; a slow answer counts as running. + public static bool IsRunning(IDistributedApplicationBuilder builder) => Resolve(builder).Running; + + private static (string Path, bool Running) Resolve(IDistributedApplicationBuilder builder) + { + var configured = builder.Configuration["DcpPublisher:ContainerRuntime"] + ?? builder.Configuration["ASPIRE_CONTAINER_RUNTIME"] + ?? builder.Configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"] + ?? ""; + + return found.GetOrAdd(configured, static configured => + { + if (configured.Length > 0) + return Check(configured); + + var docker = Check("docker"); + + if (docker.Running) + return docker; + + var podman = Check("podman"); + + return podman.Running || (File.Exists(docker.Path) is false && File.Exists(podman.Path)) ? podman : docker; + }); + } + + private static (string Path, bool Running) Check(string name) + { + var path = FindExecutable(name); + + try + { + using var process = Process.Start(new ProcessStartInfo(path, ["container", "ls", "-n", "1"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + if (process is null) + return (path, false); + + _ = process.StandardOutput.ReadToEndAsync(); + _ = process.StandardError.ReadToEndAsync(); + + if (process.WaitForExit(TimeSpan.FromSeconds(20))) + return (path, process.ExitCode is 0); + + process.Kill(entireProcessTree: true); + return (path, true); + } + catch (Win32Exception) + { + return (path, false); + } + } + + private static string FindExecutable(string name) + => (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(directory => Path.Combine(directory, OperatingSystem.IsWindows() ? $"{name}.exe" : name)) + .FirstOrDefault(File.Exists) ?? name; +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainersKeeper.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainersKeeper.cs index 15587b415dc..775c3972919 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainersKeeper.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Infrastructure/Services/ContainersKeeper.cs @@ -131,11 +131,7 @@ private static bool StartKeeper(IDistributedApplicationBuilder builder, string s // A new keeper starts with an empty list; containers that outlived a killed keeper are recorded again by the run that adopts them. File.Delete(Path.Combine(stateDirectory, "containers")); - // Same lookup order as Aspire. The full path, because the Windows keeper doesn't inherit the app host's PATH. - var containerRuntime = FindExecutable(builder.Configuration["DcpPublisher:ContainerRuntime"] - ?? builder.Configuration["ASPIRE_CONTAINER_RUNTIME"] - ?? builder.Configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"] - ?? "docker"); + var containerRuntime = ContainerRuntime.Find(builder); var processId = OperatingSystem.IsWindows() ? StartWindowsKeeper(stateDirectory, (int)idleTimeout.TotalSeconds, containerRuntime) @@ -261,11 +257,6 @@ private static void WaitWhileStopping(string stateDirectory) File.Delete(stoppingPath); } - private static string FindExecutable(string name) - => (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) - .Select(directory => Path.Combine(directory, OperatingSystem.IsWindows() ? $"{name}.exe" : name)) - .FirstOrDefault(File.Exists) ?? name; - // Same lookup order as Aspire's own DcpOptions. private static string? GetDcpPath(IDistributedApplicationBuilder builder) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs index cc1d83e62ce..ef9d2496e41 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs @@ -128,6 +128,9 @@ .WithExplicitStart(); } + // Without Docker or Podman the projects still run, only the containers are missing. See `.docs/20- .NET Aspire.md`. + builder.RunWithoutContainerRuntime(); + // Keeps the containers alive between runs and stops them once the app host hasn't run for 15 minutes. See `.docs/20- .NET Aspire.md`. builder.UsePersistentContainers(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Infrastructure/Extensions/WebApplicationExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Infrastructure/Extensions/WebApplicationExtensions.cs index 3261b2d0a5a..1bd3789ec58 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Infrastructure/Extensions/WebApplicationExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Infrastructure/Extensions/WebApplicationExtensions.cs @@ -49,11 +49,10 @@ public WebApplication MapAppHealthChecks(IAuthorizeData[]? healthzAuthorization }); // The detailed report of the health checks page: behind its feature and never cached, as it carries each - // failure's exception. Always 200, since the status is in the body and the client throws on a 503. + // failure's exception. HealthCheckOptions healthzOptions = new() { Predicate = _ => true, - ResultStatusCodes = { [HealthStatus.Unhealthy] = StatusCodes.Status200OK }, ResponseWriter = WriteHealthReport }; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignInRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignInRequestDto.cs index eedb560485c..51dad555e17 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignInRequestDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignInRequestDto.cs @@ -5,7 +5,7 @@ namespace Boilerplate.Shared.Features.Identity.Dtos; [DtoResourceType(typeof(AppStrings))] public partial class SignInRequestDto : IdentityRequestDto { - /// "123456" + /// "12345678" [Display(Name = nameof(AppStrings.Password))] public string? Password { get; set; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs index 6bbbb369ea4..6905e294b47 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs @@ -9,7 +9,7 @@ public partial class SignUpRequestDto : IdentityRequestDto [Display(Name = nameof(AppStrings.UserName))] public override string? UserName { get; set; } - /// "123456" + /// "12345678" [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] [MinLength(6, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] [Display(Name = nameof(AppStrings.Password))] diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/ContainerRuntimeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/ContainerRuntimeTests.cs new file mode 100644 index 00000000000..81183bb6921 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/ContainerRuntimeTests.cs @@ -0,0 +1,25 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Aspire.Hosting.ApplicationModel; + +namespace Boilerplate.Tests.Features.AppHost; + +[TestClass, TestCategory("UnitTest")] +public class ContainerRuntimeTests +{ + public TestContext TestContext { get; set; } = default!; + + [TestMethod] + public async Task WithoutAContainerRuntime_Containers_Should_HaveNoNamedVolumes() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(["--DcpPublisher:ContainerRuntime=bit-missing-container-runtime"], TestContext.CancellationToken); + + var containers = builder.Resources.OfType().ToArray(); + + Assert.Contains("keycloak", containers.Select(container => container.Name).ToArray(), "The app host's containers must stay in the model."); + + var withVolumes = containers.Where(container => container.Annotations.OfType().Any(mount => mount.Type is ContainerMountType.Volume)).Select(container => container.Name).ToArray(); + + Assert.IsEmpty(withVolumes, $"A named volume never gets ready without a container runtime, and Aspire then ends the whole run: {string.Join(", ", withVolumes)}."); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/KeycloakRealmImportTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/KeycloakRealmImportTests.cs index 10119a491ca..2674f61ab74 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/KeycloakRealmImportTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/AppHost/KeycloakRealmImportTests.cs @@ -5,7 +5,7 @@ namespace Boilerplate.Tests.Features.AppHost; /// -/// dev-realm.json seeds accounts with well-known passwords - test / 123456 is in g-admin, +/// dev-realm.json seeds accounts with well-known passwords - test / 12345678 is in g-admin, /// which AppJwtSecureDataFormat expands into every /// value - and an OAuth client whose secret is committed in this repository. It is a development fixture. /// diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductImageCacheInvalidationTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductImageCacheInvalidationTests.cs index 08e31ed8d07..042f2f306ba 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductImageCacheInvalidationTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductImageCacheInvalidationTests.cs @@ -28,7 +28,7 @@ public class ProductImageCacheInvalidationTests { // Seeded tenant-admin of the default (fallback) tenant; holds ProductCatalog_Manage. See UserConfiguration. private const string TenantAdminEmail = "store-admin@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; public TestContext TestContext { get; set; } = default!; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductResponseCacheTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductResponseCacheTests.cs index 5514ba218bd..c67c03912d6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductResponseCacheTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Caching/ProductResponseCacheTests.cs @@ -14,7 +14,7 @@ public partial class ProductResponseCacheTests // Seeded members of the default (fallback) store tenant. See UserConfiguration and TenantUserConfiguration. private const string TenantAdminEmail = "store-admin@bitplatform.dev"; private const string TenantUserEmail = "store-user@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; public TestContext TestContext { get; set; } = default!; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Categories/CategoryDeleteGuardTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Categories/CategoryDeleteGuardTests.cs index 954cf16067a..0575396567b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Categories/CategoryDeleteGuardTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Categories/CategoryDeleteGuardTests.cs @@ -30,7 +30,7 @@ public partial class CategoryDeleteGuardTests { // Seeded tenant-admin of the default (fallback) tenant; holds ProductCatalog_Manage. See UserConfiguration. private const string TenantAdminEmail = "store-admin@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; public TestContext TestContext { get; set; } = default!; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Chatbot/AiChatPanelHistoryUITests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Chatbot/AiChatPanelHistoryUITests.cs index 047f32cab83..a90dec224bf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Chatbot/AiChatPanelHistoryUITests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Chatbot/AiChatPanelHistoryUITests.cs @@ -32,7 +32,7 @@ public partial class AiChatPanelHistoryUITests : AiChatPanelTestBase private const string PersianCulture = "fa-IR"; private const string StoreAdminEmail = "store-admin@bitplatform.dev"; - private const string StoreAdminPassword = "123456"; + private const string StoreAdminPassword = "12345678"; [TestMethod] public async Task ASignedInUsersConversation_Should_SurviveTheSoftRestartOfACultureChange() diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Diagnostics/HealthCheckIntegrationTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Diagnostics/HealthCheckIntegrationTests.cs index b8ad4901254..0333481189b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Diagnostics/HealthCheckIntegrationTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Diagnostics/HealthCheckIntegrationTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Diagnostics.HealthChecks; using Boilerplate.Shared.Features.Diagnostic; +using Boilerplate.Client.Core.Infrastructure.Services.HttpMessageHandlers; //#if (cloudflare == true) using System.Text; using System.Text.Json.Nodes; @@ -54,8 +55,8 @@ public async Task Health_Should_ReportHealthy() } /// - /// /healthz needs , is never cached, and answers 200 even - /// when a check is Unhealthy. + /// /healthz needs , is never cached, and answers 503 when a + /// check is Unhealthy, with the report in the body all the same. /// [TestMethod] public async Task DetailedReport_Should_NeedTheFeature_AndDescribeEveryCheck() @@ -91,9 +92,9 @@ await adminClient.AuthManager.SignIn(new() Password = TestData.DefaultTestPassword }, TestContext.CancellationToken); - using var response = await adminClient.HttpClient.GetAsync(healthzUrl, TestContext.CancellationToken); + using var response = await GetReport(adminClient.HttpClient, healthzUrl); - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "An Unhealthy report still answers 200."); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode, "An Unhealthy report answers 503, the way /health does."); Assert.IsNotNull(response.Headers.CacheControl); Assert.IsTrue(response.Headers.CacheControl.NoStore, "The report must not be cached anywhere."); @@ -117,6 +118,14 @@ await adminClient.AuthManager.SignIn(new() Assert.IsTrue(smtp.Data.ContainsKey("CheckedAt"), "A cached check says when it really ran."); } + private async Task GetReport(HttpClient httpClient, Uri url) + { + using HttpRequestMessage request = new(HttpMethod.Get, url); + request.Options.Set(new HttpRequestOptionsKey(RequestOptionNames.AllowedStatusCodes), [HttpStatusCode.ServiceUnavailable]); + + return await httpClient.SendAsync(request, TestContext.CancellationToken); + } + /// /// /healthz/v1 is the same report under the api version the controllers carry, behind the same feature. /// @@ -142,9 +151,7 @@ await adminClient.AuthManager.SignIn(new() Password = TestData.DefaultTestPassword }, TestContext.CancellationToken); - using var response = await adminClient.HttpClient.GetAsync(healthzUrl, TestContext.CancellationToken); - - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + using var response = await GetReport(adminClient.HttpClient, healthzUrl); var report = await response.Content.ReadFromJsonAsync( adminClient.Services.GetRequiredService().GetTypeInfo(), TestContext.CancellationToken); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/TestData.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/TestData.cs index 645e30fc97a..766f55eb86e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/TestData.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/TestData.cs @@ -3,6 +3,6 @@ namespace Boilerplate.Tests.Features.Identity; public partial class TestData { public const string DefaultTestEmail = "test@bitplatform.dev"; - public const string DefaultTestPassword = "123456"; + public const string DefaultTestPassword = "12345678"; public const string DefaultTestFullName = "Boilerplate test account"; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UserGroupFeatureManagementUITests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UserGroupFeatureManagementUITests.cs index d683cbed8f7..4d8d87ad35c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UserGroupFeatureManagementUITests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UserGroupFeatureManagementUITests.cs @@ -10,7 +10,7 @@ public partial class UserGroupFeatureManagementUITests : AppPageTest // She is only ever the ACTOR here: the user-group she edits and the member who signs in are both created per run, // so this test never mutates anything another test - or its own next run - depends on. private const string StoreAdminEmail = "store-admin@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; // The "Manage roles" (a.k.a. "User groups") page is gated behind the Roles_Manage feature. In the role editor's // Features tab, each feature's leaf is labelled with its raw field name. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/AddOrEditProductPageTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/AddOrEditProductPageTests.cs index 24f314e26fe..5cd254e04ad 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/AddOrEditProductPageTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/AddOrEditProductPageTests.cs @@ -33,7 +33,7 @@ public class AddOrEditProductPageTests // Seeded tenant-admin of the fallback tenant; holds PRIVILEGED_ACCESS, a selected tenant and ProductCatalog_Manage, // which is exactly what ProductController and CategoryController demand. See UserConfiguration. private const string TenantAdminEmail = "store-admin@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; /// /// BP-518. Asserts the observable consequence - a rendered message - rather than the presence of a particular diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/ProductImageLifecycleTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/ProductImageLifecycleTests.cs index 35e6f6e15f5..c83507d7667 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/ProductImageLifecycleTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Products/ProductImageLifecycleTests.cs @@ -37,7 +37,7 @@ public partial class ProductImageLifecycleTests { // Seeded tenant-admin of the default (fallback) tenant; holds ProductCatalog_Manage. See UserConfiguration. private const string TenantAdminEmail = "store-admin@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; public TestContext TestContext { get; set; } = default!; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/SignalR/DashboardBroadcastTenantScopeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/SignalR/DashboardBroadcastTenantScopeTests.cs index 0d3f2c0e6bf..661d2f7b938 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/SignalR/DashboardBroadcastTenantScopeTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/SignalR/DashboardBroadcastTenantScopeTests.cs @@ -20,7 +20,7 @@ public partial class DashboardBroadcastTenantScopeTests { // Seeded tenant-admin of the default (fallback) "store" tenant; holds ProductCatalog_Manage. See UserConfiguration. private const string StoreAdminEmail = "store-admin@bitplatform.dev"; - private const string Password = "123456"; + private const string Password = "12345678"; public TestContext TestContext { get; set; } = default!; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Tenants/TenantInvitationUITests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Tenants/TenantInvitationUITests.cs index f35f11864c1..84e7f95fb35 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Tenants/TenantInvitationUITests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Tenants/TenantInvitationUITests.cs @@ -7,7 +7,7 @@ public partial class TenantInvitationUITests : AppPageTest { // The seeded store tenant admin. She's a t-admin (not a global admin), See UserConfiguration. private const string StoreAdminEmail = "store-admin@bitplatform.dev"; - private const string StoreAdminPassword = "123456"; + private const string StoreAdminPassword = "12345678"; /// /// End-to-end multi-tenant invitation journey across two isolated browsers: diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/CreateProjectCommandBar.razor.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/CreateProjectCommandBar.razor.cs index 579c5fbe7d9..d93ac8453d2 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/CreateProjectCommandBar.razor.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/CreateProjectCommandBar.razor.cs @@ -16,7 +16,7 @@ private void RecordCopiedCommand() return; copiedCommand = command; - _ = RecordCreatedProject(command); + _ = RecordCreatedProject(Setup.ToCommand(withName: false)); } private async Task RecordCreatedProject(string command) diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/ProjectSetup.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/ProjectSetup.cs index 711ef220fff..be91b65ed2d 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/ProjectSetup.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/CreateProject/ProjectSetup.cs @@ -70,10 +70,10 @@ public void ApplyPreset(ProjectSetup preset) Name = name; } - public string ToCommand() + public string ToCommand(bool withName = true) { var defaults = new ProjectSetup(); - StringBuilder command = new($"bit new {Name} "); + StringBuilder command = new(withName ? $"bit new {Name} " : "bit new "); Choice(command, "captcha", Captcha, defaults.Captcha); Choice(command, "pipeline", Pipeline, defaults.Pipeline); diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates02GettingStartedPage.razor b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates02GettingStartedPage.razor index dc69e93e966..a701406a416 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates02GettingStartedPage.razor +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates02GettingStartedPage.razor @@ -67,17 +67,22 @@ source ~/.bashrc bit new MyFirstProject --yes
The bit new command checks this machine and offers to install what's missing: - Node.js, Git, Docker, the Aspire CLI, VS Code and more. What the project needs is checked already; the rest is up to you, + Node.js, Git, a container runtime, the Aspire CLI, VS Code and more. What the project needs is checked already; the rest is up to you, and anything that's already there isn't shown at all. When an install needs it, Windows asks once for administrator permission, and on macOS and Linux sudo asks once for your password.

- It also warns, only when it's sure, if virtualization is turned off in the BIOS while Docker Desktop or the Android emulator needs it, - if the machine has less than 24 GB of memory, or if the project's drive is a hard disk rather than an SSD. + The container runtime is one that's free for commercial use: Podman on Windows, Colima with the docker CLI on macOS, and Docker Engine on Linux. + A Docker you already have is used as it is. A project whose database is SQLite and that has no Redis runs without it too, only without Keycloak and the test email inbox. +
+
+ It also warns, only when it's sure, if virtualization is turned off in the BIOS while the project's containers or the Android emulator need it, + if the machine has less than 15 GB of memory, or if the project's drive is a hard disk rather than an SSD.

Then it creates the project with a certificate of its own, initializes git with develop and main, installs the build tools the apps need, restores the packages, builds, installs the Chromium the UI tests run in, formats the code, adds the initial EF Core migration, - trusts the folder for VS Code and the AI coding tools, installs the VS Code extensions the project recommends, and opens it in VS Code. + trusts the folder for VS Code and the AI coding tools, installs the VS Code extensions the project recommends, starts the project, and opens it in VS Code. + Then it opens the Aspire dashboard and the app in your browser, and keeps the project running while that terminal is open. Then use any AI assistant you like: the project comes fully configured for Claude Code, GitHub Copilot, Codex, Gemini CLI and Cursor. A step that fails doesn't stop the rest, and the summary shows how to finish it.
@@ -110,8 +115,8 @@ source ~/.bashrc
- - Docker Desktop and the Android emulators need hardware virtualization, which is turned on in the BIOS or UEFI settings. + + Containers and the Android emulators need hardware virtualization, which is turned on in the BIOS or UEFI settings. Task Manager shows whether it's on:

diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor index 7c7682a4a6a..cfdf0708662 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor @@ -82,11 +82,12 @@ @foreach (var answer in group.Answers) { var chosen = answer.IsChosen(); -
+
diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs index 51bff8f1dbd..8fa6d904c79 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs @@ -14,7 +14,7 @@ public partial class Templates04CreateProjectPage private static readonly Faq[] finalFaqs = [ new("What does bit new install on my machine?", - "It checks this machine and installs what's missing, such as the .NET SDK, Node.js, Git, Docker when Aspire needs it and VS Code. Then it creates the project, sets up git, builds it, adds the first database migration, installs the recommended editor extensions and opens the project. A step that fails doesn't stop the others."), + "It checks this machine and installs what's missing, such as the .NET SDK, Node.js, Git, a container runtime that's free for commercial use when Aspire needs one, and VS Code. Then it creates the project, sets up git, builds it, adds the first database migration, installs the recommended editor extensions, opens the project, and starts it in your browser. A step that fails doesn't stop the others."), new("How do I run my new project?", "If you use an AI assistant, just ask it to run the project. Otherwise, follow the [Run project](/templates/run-project) page.") ]; @@ -121,6 +121,9 @@ private void StartOver() private void Choose(ChoiceGroup group, Answer answer) { + if (answer.IsLocked) + return; + answer.Choose(); foreach (var key in group.Keys) @@ -474,7 +477,7 @@ private Step[] CreateSteps() => Key = "database", Label = "Data", Question = "Where should it keep its data?", - Help = "While you develop, PostgreSQL, SQL Server and MySQL run in a container for you. bit new offers to install Docker if it's missing.", + Help = "While you develop, PostgreSQL, SQL Server and MySQL run in a container for you. bit new installs a free container runtime if it's missing: Podman on Windows, Colima on macOS, Docker Engine on Linux.", Groups = [ new() @@ -808,17 +811,11 @@ private Step[] CreateSteps() => [ new() { - Text = "Just the web app", - Hint = "Quickest to set up.", - IsNone = true, - IsChosen = () => (setup.Android || setup.Ios || setup.MacOS || setup.Windows) is false, - Choose = () => - { - setup.Android = false; - setup.Ios = false; - setup.MacOS = false; - setup.Windows = false; - } + Text = "Web", + Hint = "Always set up, and the quickest one.", + IsLocked = true, + IsChosen = () => true, + Choose = () => { } }, Platform("Android", "Builds the Android app now.", () => setup.Android, v => setup.Android = v), Platform("iOS", "Builds the iOS app now.", () => setup.Ios, v => setup.Ios = v), @@ -854,6 +851,7 @@ private sealed class Answer public string? Hint { get; init; } public string? Summary { get; init; } public bool IsNone { get; init; } + public bool IsLocked { get; init; } public required Func IsChosen { get; init; } public required Action Choose { get; init; } public Func IsRecommended { get; init; } = () => false; diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.scss b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.scss index 20c49a33284..11b42f46df8 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.scss +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.scss @@ -217,6 +217,22 @@ box-shadow: inset 0 0 0 rem2(3px) $bit-color-background-secondary; } +.answer.locked { + cursor: default; + + &:hover { + border-color: $bit-color-primary; + } + + .answer-hit { + cursor: default; + } + + .answer-mark { + opacity: 0.6; + } +} + .answer.chosen .answer-mark.square { box-shadow: none; diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates05RunProjectPage.razor b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates05RunProjectPage.razor index 809dbb89e5d..fff829b4f13 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates05RunProjectPage.razor +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates05RunProjectPage.razor @@ -29,7 +29,7 @@ --api Standalone: Run both the Server.Api and Server.Web projects. - Note: You have to make sure that the connection strings section of the Server.Api is properly configured based on the database of your choice installation. However, if you've created the project with Aspire (which is enabled by default) and you've installed Docker Desktop, you can simply run the AppHost project. This will run all dependencies as Docker containers and provide you with an awesome handy dashboard as well. + Note: You have to make sure that the connection strings section of the Server.Api is properly configured based on the database of your choice installation. However, if you've created the project with Aspire (which is enabled by default) and Docker or Podman is running, you can simply run the AppHost project. This will run all dependencies as Docker containers and provide you with an awesome handy dashboard as well.

@@ -42,7 +42,7 @@ Email: test@bitplatform.dev
  • - Password: 123456 + Password: a random 8 digit number, different for every project. bit new shows it at the end, and the project's README.md has it.
  • diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/TelegramBotService.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/TelegramBotService.cs index e03bd6d4db3..30e2463cde1 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/TelegramBotService.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Server/Services/TelegramBotService.cs @@ -34,12 +34,15 @@ public async Task SendMcpFeedbackMessage(string title, string report, stri [GeneratedRegex(@"[_*`\[]")] private static partial Regex MarkdownSpecialCharacters(); + [GeneratedRegex(@"^bit\s+new\s+(?!-)\S+\s*")] + private static partial Regex ProjectNameInCommand(); + public async Task SendProjectCreatedMessage(string command, CancellationToken cancellationToken) { var messageBuilder = new StringBuilder(); messageBuilder.AppendLine("🚀 *A project is being created*"); - messageBuilder.AppendLine($"`{command.Replace("`", "", StringComparison.Ordinal).Trim()}`"); + messageBuilder.AppendLine($"`{ProjectNameInCommand().Replace(command.Replace("`", "", StringComparison.Ordinal).Trim(), "bit new ")}`"); await telegramBotApiClient.SendMessageAsync(messageBuilder.ToString(), cancellationToken); } From 8cd79e08114315ad955dbc926b32deb78126d470 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Mon, 19 Oct 2026 23:33:24 +0200 Subject: [PATCH 02/12] fix(cli): podman test path on every OS #13974 --- src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs index b546d434b67..a879d3df624 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs @@ -93,7 +93,7 @@ public async Task Podman_Should_CountAsTheContainerRuntime() using var host = new TestHost(HostOs.Windows); AllInstalled(host.Runner); host.Runner.NotFound("docker"); - host.Runner.Executables["podman"] = @"C:\Podman\podman.exe"; + host.Runner.Executables["podman"] = "podman"; host.Runner.On("podman", "--version", 0, "podman version 6.1.4"); var running = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker"); From c3f2990777433b83b6f54be7be23de5e74cf7bf9 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 08:41:43 +0200 Subject: [PATCH 03/12] fix(templates): demos use 12345678 too #13974 --- .github/workflows/admin-sample.cd.yml | 10 +++++----- .github/workflows/sales-module-demo.cd.yml | 4 ++-- .github/workflows/todo-sample.cd.yml | 16 ++++++++-------- .../Features/Antiforgery/AntiforgeryTests.cs | 2 +- .../AttachmentMetadataStrippingTests.cs | 2 +- .../FeatureGating/WebFeatureGatingTests.cs | 2 +- .../Features/Gdpr/PersonalDataJourneyTests.cs | 2 +- .../Identity/WebPrivilegedSessionTests.cs | 2 +- .../Features/Jobs/RetentionJobsTests.cs | 2 +- .../TenantInvitationJourneyTestBase.cs | 2 +- .../Multitenancy/WebTenantIsolationTests.cs | 2 +- .../Realtime/RealtimeProfileTestsBase.cs | 2 +- .../Features/Smoke/ApiSmokeTests.cs | 2 +- .../Features/Todo/WebOfflineTodoSyncTests.cs | 2 +- .../Infrastructure/StoreAdmin.cs | 2 +- .../Infrastructure/StoreUser.cs | 2 +- .../Pages/DemosPage.razor | 2 +- .../Templates04CreateProjectPage.razor.cs | 2 +- 18 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/admin-sample.cd.yml b/.github/workflows/admin-sample.cd.yml index a973e090aec..b7a9a4c19ba 100644 --- a/.github/workflows/admin-sample.cd.yml +++ b/.github/workflows/admin-sample.cd.yml @@ -100,7 +100,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --database PostgreSQL --module Admin --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -257,7 +257,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --theme Material --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --theme Material --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -318,7 +318,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -420,7 +420,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust - name: Extract Android signing key from env uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c # v2.0.0 @@ -515,7 +515,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | diff --git a/.github/workflows/sales-module-demo.cd.yml b/.github/workflows/sales-module-demo.cd.yml index d0f67e07858..c4edca95c53 100644 --- a/.github/workflows/sales-module-demo.cd.yml +++ b/.github/workflows/sales-module-demo.cd.yml @@ -76,7 +76,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new SalesModule --database PostgreSQL --module Sales --appInsights --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new SalesModule --seedPassword 12345678 --database PostgreSQL --module Sales --appInsights --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -191,7 +191,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new SalesModule --database PostgreSQL --module Sales --sentry --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust + run: bit new SalesModule --seedPassword 12345678 --database PostgreSQL --module Sales --sentry --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 diff --git a/.github/workflows/todo-sample.cd.yml b/.github/workflows/todo-sample.cd.yml index e77e92b3be4..4b86dd217bd 100644 --- a/.github/workflows/todo-sample.cd.yml +++ b/.github/workflows/todo-sample.cd.yml @@ -99,7 +99,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --api Standalone --redis --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --api Standalone --redis --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -172,7 +172,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -311,7 +311,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --theme Cupertino --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --theme Cupertino --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -365,7 +365,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --multitenant false --theme Fluent --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --multitenant false --theme Fluent --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -420,7 +420,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --brouter false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --brouter false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -479,7 +479,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -581,7 +581,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust - name: Extract Android signing key from env uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c # v2.0.0 @@ -696,7 +696,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Antiforgery/AntiforgeryTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Antiforgery/AntiforgeryTests.cs index f72b17f0ab3..64ea02abcc5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Antiforgery/AntiforgeryTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Antiforgery/AntiforgeryTests.cs @@ -18,7 +18,7 @@ namespace Boilerplate.Tests.E2E.Features.Antiforgery; [TestClass, TestCategory(TestCategories.Api), Retry(2)] public partial class AntiforgeryTests { - private const string password = "123456"; + private const string password = "12345678"; /// AutoCsrfProtectionFilter's own refusal. private const string refusal = "Anti-forgery token validation failed"; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Attachments/AttachmentMetadataStrippingTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Attachments/AttachmentMetadataStrippingTests.cs index 3ef50f96792..176c5f2ccff 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Attachments/AttachmentMetadataStrippingTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Attachments/AttachmentMetadataStrippingTests.cs @@ -20,7 +20,7 @@ namespace Boilerplate.Tests.E2E.Features.Attachments; [TestClass, TestCategory(TestCategories.Api), Retry(2)] public class AttachmentMetadataStrippingTests { - private const string password = "123456"; + private const string password = "12345678"; private readonly List> cleanups = []; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/FeatureGating/WebFeatureGatingTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/FeatureGating/WebFeatureGatingTests.cs index 3033c57631d..349d7eb9cac 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/FeatureGating/WebFeatureGatingTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/FeatureGating/WebFeatureGatingTests.cs @@ -11,7 +11,7 @@ namespace Boilerplate.Tests.E2E.Features.FeatureGating; [TestClass, TestCategory(TestCategories.Web), Retry(2)] public partial class WebFeatureGatingTests : AppTestBase { - private const string password = "123456"; + private const string password = "12345678"; protected override IAppOpener AppOpener => new WebAppOpener(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Gdpr/PersonalDataJourneyTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Gdpr/PersonalDataJourneyTests.cs index 8b42c08eb70..97333e8ea06 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Gdpr/PersonalDataJourneyTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Gdpr/PersonalDataJourneyTests.cs @@ -25,7 +25,7 @@ namespace Boilerplate.Tests.E2E.Features.Gdpr; [TestClass, TestCategory(TestCategories.Api), Retry(2)] public partial class PersonalDataJourneyTests { - private const string password = "123456"; + private const string password = "12345678"; /// /// The sources Sales registers, in the order IPersonalDataSource.Order puts them. No todoItems: that diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Identity/WebPrivilegedSessionTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Identity/WebPrivilegedSessionTests.cs index c689f3ab186..2ed84d0d97c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Identity/WebPrivilegedSessionTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Identity/WebPrivilegedSessionTests.cs @@ -13,7 +13,7 @@ namespace Boilerplate.Tests.E2E.Features.Identity; [TestClass, TestCategory(TestCategories.Web), Retry(2)] public partial class WebPrivilegedSessionTests : AppTestBase { - private const string password = "123456"; + private const string password = "12345678"; /// The deployment's own Identity:MaxPrivilegedSessionsCount. private const int maxPrivilegedSessions = 3; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Jobs/RetentionJobsTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Jobs/RetentionJobsTests.cs index 04f9672a1fa..0334bdc285c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Jobs/RetentionJobsTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Jobs/RetentionJobsTests.cs @@ -27,7 +27,7 @@ namespace Boilerplate.Tests.E2E.Features.Jobs; [TestClass, TestCategory(TestCategories.Api), Retry(2)] public partial class RetentionJobsTests { - private const string password = "123456"; + private const string password = "12345678"; /// /// AdminPanelApi, because this one runs the jobs rather than only looking at them. All three deployments share a diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/TenantInvitationJourneyTestBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/TenantInvitationJourneyTestBase.cs index e932de1d74b..5bb27d4bbbd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/TenantInvitationJourneyTestBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/TenantInvitationJourneyTestBase.cs @@ -11,7 +11,7 @@ namespace Boilerplate.Tests.E2E.Features.Multitenancy; public abstract class TenantInvitationJourneyTestBase : AppTestBase { private static readonly Uri adminPanelAppUrl = new(DeployedApps.AdminPanel); - private const string password = "123456"; + private const string password = "12345678"; private const string e2eTenantFallback = "e2e"; /// diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/WebTenantIsolationTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/WebTenantIsolationTests.cs index 467279aed33..6bb12efe6dd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/WebTenantIsolationTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Multitenancy/WebTenantIsolationTests.cs @@ -17,7 +17,7 @@ namespace Boilerplate.Tests.E2E.Features.Multitenancy; [TestClass, TestCategory(TestCategories.Web), Retry(2)] public partial class WebTenantIsolationTests : AppTestBase { - private const string password = "123456"; + private const string password = "12345678"; protected override IAppOpener AppOpener => new WebAppOpener(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Realtime/RealtimeProfileTestsBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Realtime/RealtimeProfileTestsBase.cs index 5db57a8251a..81edf654440 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Realtime/RealtimeProfileTestsBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Realtime/RealtimeProfileTestsBase.cs @@ -14,7 +14,7 @@ namespace Boilerplate.Tests.E2E.Features.Realtime; /// public abstract partial class RealtimeProfileTestsBase : AppTestBase { - protected const string password = "123456"; + protected const string password = "12345678"; /// /// says which of the two sessions posts the picture. Every head does both: CDP diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Smoke/ApiSmokeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Smoke/ApiSmokeTests.cs index c60c9ddfbe9..cc79fc6a423 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Smoke/ApiSmokeTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Smoke/ApiSmokeTests.cs @@ -11,7 +11,7 @@ public partial class ApiSmokeTests { /// The seeded non-admin member of the default store tenant; see UserConfiguration. private const string storeUserEmail = "store-user@bitplatform.dev"; - private const string storeUserPassword = "123456"; + private const string storeUserPassword = "12345678"; public TestContext TestContext { get; set; } = default!; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Todo/WebOfflineTodoSyncTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Todo/WebOfflineTodoSyncTests.cs index 8fd38136a3d..6e6bf299cdf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Todo/WebOfflineTodoSyncTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Todo/WebOfflineTodoSyncTests.cs @@ -13,7 +13,7 @@ namespace Boilerplate.Tests.E2E.Features.Todo; [TestClass, TestCategory(TestCategories.Web), Retry(2)] public partial class WebOfflineTodoSyncTests : AppTestBase { - private const string password = "123456"; + private const string password = "12345678"; protected override IAppOpener AppOpener => new WebAppOpener(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreAdmin.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreAdmin.cs index 2f76b553dcf..7889828d368 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreAdmin.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreAdmin.cs @@ -7,5 +7,5 @@ namespace Boilerplate.Tests.E2E.Infrastructure; public static class StoreAdmin { public const string Email = "store-admin@bitplatform.dev"; - public const string Password = "123456"; + public const string Password = "12345678"; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreUser.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreUser.cs index 09ec8d84f86..f854ec22d28 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreUser.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/StoreUser.cs @@ -7,5 +7,5 @@ namespace Boilerplate.Tests.E2E.Infrastructure; public static class StoreUser { public const string Email = "store-user@bitplatform.dev"; - public const string Password = "123456"; + public const string Password = "12345678"; } diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/DemosPage.razor b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/DemosPage.razor index 8ef57952094..a27ca7af368 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/DemosPage.razor +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/DemosPage.razor @@ -105,7 +105,7 @@
    Check out the following fully functional demo apps to see the Boilerplate in action across various platforms.
    - (use store-user@bitplatform.dev as the email and 123456 as the password to log in) + (use store-user@bitplatform.dev as the email and 12345678 as the password to log in)



    diff --git a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs index 8fa6d904c79..c5bbc5023b7 100644 --- a/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs +++ b/src/Websites/Platform/src/Bit.Websites.Platform.Client/Pages/Templates/Templates04CreateProjectPage.razor.cs @@ -450,7 +450,7 @@ private Step[] CreateSteps() => Key = "multitenant", Label = "Many organizations (multi-tenancy)", Question = "Will several companies or teams use it, each seeing only their own data?", - Help = "This is known as multi-tenancy. Try it in the admin panel demo: sign in as store-user@bitplatform.dev with password 123456.", + Help = "This is known as multi-tenancy. Try it in the admin panel demo: sign in as store-user@bitplatform.dev with password 12345678.", Groups = [ new() From 1c4e6c07d89aab94123dc3f898c2a3a26c00ee57 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 09:10:45 +0200 Subject: [PATCH 04/12] fix(cli): random password stays in dotnet new, bit diff works in temp #13974 --- .github/agents/test-platform.md | 34 ++++++++++++++++--- .github/workflows/admin-sample.cd.yml | 10 +++--- .github/workflows/sales-module-demo.cd.yml | 4 +-- .github/workflows/todo-sample.cd.yml | 16 ++++----- src/Cli/Bit.Cli/Commands/DiffCommand.cs | 4 +-- src/Cli/Bit.Cli/Commands/DoctorCommand.cs | 2 +- src/Cli/Bit.Cli/Projects/NewWorkflow.cs | 21 ++---------- src/Cli/Bit.Cli/Projects/TemplateDiff.cs | 24 ++++++------- src/Cli/Bit.Cli/Templates/TemplateLabels.cs | 3 +- src/Cli/README.md | 9 +++-- src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs | 27 ++------------- .../.docs/18- Available Agent Skills.md | 4 +-- .../.github/agents/upgrade.agent.md | 6 ++-- .../.template.config/ide.host.json | 4 --- .../.template.config/template.json | 16 +-------- .../ExceptionDelegatingHandler.cs | 1 + .../Extensions/PlaywrightPageExtensions.cs | 4 --- 17 files changed, 74 insertions(+), 115 deletions(-) diff --git a/.github/agents/test-platform.md b/.github/agents/test-platform.md index b2665842688..5c6f6bb5915 100644 --- a/.github/agents/test-platform.md +++ b/.github/agents/test-platform.md @@ -1,6 +1,6 @@ --- name: test-platform -description: Test one build of bitplatform end to end on the demo host - fix what its All CI run failed on, start an All CD run at a new APP_VERSION when asked to, roll its web, Android and Windows apps out to APP_VERSION, run the Boilerplate E2E suite against the deployed demos, check that bitplatform.dev/mcp answers each release from that release's own code, check that a new bit Boilerplate project's dev container sets itself up with bit and runs the app, and check that its Aspire containers are reused from one run to the next and stopped once it sits idle, on Windows and inside the dev container. It first asks which of these to do and how. Use when asked to test the platform or to start the e2e tests, or when handed an All CI and/or All CD run link. +description: Test one build of bitplatform end to end on the demo host - fix what its All CI run failed on, start an All CD run at a new APP_VERSION when asked to, roll its web, Android and Windows apps out to APP_VERSION, run the Boilerplate E2E suite against the deployed demos, check that bitplatform.dev/mcp answers each release from that release's own code, check that a new bit Boilerplate project's dev container sets itself up with bit and runs the app, check that its Aspire containers are reused from one run to the next and stopped once it sits idle, on Windows and inside the dev container, and check that bit diff compares two bit Boilerplate versions. It first asks which of these to do and how. Use when asked to test the platform or to start the e2e tests, or when handed an All CI and/or All CD run link. --- # Test the platform @@ -55,7 +55,8 @@ answer, and repeat the plan back before starting: checkout of that branch, pulled first: the helper takes the suite, its stages and the CD workflows from the checkout it runs in. 2. **The tests**: any of the CI run (step 1), the CD rollout and its versions check (step 2), the E2E suite (step 3, - every stage or some), bitplatform.dev/mcp (step 4), the dev container (step 5) and the Aspire containers (step 6). + every stage or some), bitplatform.dev/mcp (step 4), the dev container (step 5), the Aspire containers (step 6) + and `bit diff` (step 7). 3. **The Bit packages**: nuget.org's, or the build of a Prerelease nuget packages run (`prerelease_packages`). The latter needs a successful prerelease run on the branch under test first. It publishes the branch's version to nuget.org only when nuget.org doesn't have it yet, so at a version that is already there it builds packages and @@ -65,7 +66,7 @@ answer, and repeat the plan back before starting: or none, which tests what is deployed now. Say which branch and `APP_VERSION` that is, from the last All CD run (`gh run list -R bitfoundation/bitplatform -w all.cd.yml -L 1`): it is often another branch than the one under test. 6. **`APP_VERSION`**, for a new CD run: raise it, and to which version, or keep it. -7. **Steps 5 and 6**: from the commit under test (`-Sha`) or from nuget.org. With `-Sha` the other Bit packages still +7. **Steps 5, 6 and 7**: from the commit under test (`-Sha`) or from nuget.org. With `-Sha` the other Bit packages still come from nuget.org at the template's version; when nuget.org's build of that version is older than the commit's libraries, `-PrereleasePackages` takes them from the newest Prerelease nuget packages run instead. @@ -79,6 +80,7 @@ until the report. Then go in this order: its jobs and all default to on; `-f =false` leaves one out. 3. While they run: step 1 as CI jobs fail. 4. Once CD is done: step 2, then steps 3 and 4, with step 6 in the background next to the web stages of step 3. + Step 7 needs no CD and is light: do it while CI and CD run. 5. Step 5 last, on its own. Every CD job builds with `-p:Version=$APP_VERSION`, so a complete rollout shows that version everywhere: as @@ -469,7 +471,27 @@ the next Aspire run removes and the idle stop doesn't touch. A failure is fixed in the template like a CI failure, and the next `aspire -Sha` on the fix proves it. -## 7. Report +## 7. bit diff + +`bit diff` creates a project with two bit Boilerplate versions into one git repository in the temp folder, so its +second commit is the upgrade, and the template's `upgrade` skill applies that to a project. The helper has no command +for it, so check it by hand, with bit and the template of the commit under test, or nuget.org's: + +- In a lab folder outside the repository and the scratchpad, with `BIT_CLI_HOME` pointing into it, create a project + from the previous release with `bit new --template-version`, skipping the run, the IDE and trust. Then run `bit diff` + in it, against the template folder of the checkout under test (`--template-package`) or the version under test. +- Run a bit built from the commit with `dotnet` on its `bit.dll`. `dotnet run --project` from a folder whose SDK is + another one hands its MSBuild variables to every build bit starts, which then fail, and leave hundreds of MSBuild + processes behind. +- Done means: the name, version and options come from the project's `README.md`; there are two commits named after + the versions and a patch file in the diff's folder; the project itself is unchanged; and the differences are the + template's own changes plus only what the template picks at random on every run (ports, GUIDs, dates, the seeded + accounts' password). An option the older version doesn't have is reported, not passed to it. +- `bit remove` deletes the diff's folder and the lab afterwards. + +A failure is fixed in bit like a CI failure. + +## 8. Report Lead with the verdict, then: @@ -483,7 +505,9 @@ Lead with the verdict, then: exit code, the Aspire check inside it as in item 6, and per failure its cause and fix. 6. **Aspire containers**: the commit or the nuget.org version tested, how soon each run was up, whether the second run reused every container, how long after the last run they stopped, and per failure its cause and fix. -7. **Waiting for a CD**: every fix that only the next CD can prove, and for a library fix whether it can be tried +7. **bit diff**: the versions compared, the size of the change, whether the README's values were read, any + difference that is neither an upgrade nor random per run, and per failure its cause and fix. +8. **Waiting for a CD**: every fix that only the next CD can prove, and for a library fix whether it can be tried through `prerelease_packages` before its release. Anything that looks like a defect in a bit platform library or in the `bit-bp` template itself: say so, and offer to diff --git a/.github/workflows/admin-sample.cd.yml b/.github/workflows/admin-sample.cd.yml index b7a9a4c19ba..a973e090aec 100644 --- a/.github/workflows/admin-sample.cd.yml +++ b/.github/workflows/admin-sample.cd.yml @@ -100,7 +100,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --database PostgreSQL --module Admin --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -257,7 +257,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --theme Material --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --theme Material --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -318,7 +318,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -420,7 +420,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust - name: Extract Android signing key from env uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c # v2.0.0 @@ -515,7 +515,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new AdminPanel --seedPassword 12345678 --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust + run: bit new AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | diff --git a/.github/workflows/sales-module-demo.cd.yml b/.github/workflows/sales-module-demo.cd.yml index c4edca95c53..d0f67e07858 100644 --- a/.github/workflows/sales-module-demo.cd.yml +++ b/.github/workflows/sales-module-demo.cd.yml @@ -76,7 +76,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new SalesModule --seedPassword 12345678 --database PostgreSQL --module Sales --appInsights --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new SalesModule --database PostgreSQL --module Sales --appInsights --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -191,7 +191,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new SalesModule --seedPassword 12345678 --database PostgreSQL --module Sales --sentry --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust + run: bit new SalesModule --database PostgreSQL --module Sales --sentry --apiServerUrl ${{ env.SERVER_ADDRESS }} --webAppUrl ${{ env.SERVER_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 diff --git a/.github/workflows/todo-sample.cd.yml b/.github/workflows/todo-sample.cd.yml index 4b86dd217bd..e77e92b3be4 100644 --- a/.github/workflows/todo-sample.cd.yml +++ b/.github/workflows/todo-sample.cd.yml @@ -99,7 +99,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --api Standalone --redis --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --api Standalone --redis --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -172,7 +172,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --api Standalone --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -311,7 +311,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --theme Cupertino --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --theme Cupertino --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -365,7 +365,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --multitenant false --theme Fluent --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --offlineDb --signalR --ads --multitenant false --theme Fluent --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-restore --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -420,7 +420,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --brouter false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --ads --multitenant false --brouter false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --no-build --no-format --no-migration --no-trust - name: Update core appsettings.json uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2 @@ -479,7 +479,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms windows --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | @@ -581,7 +581,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms android --no-build --no-format --no-migration --no-trust - name: Extract Android signing key from env uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c # v2.0.0 @@ -696,7 +696,7 @@ jobs: uses: ./.github/actions/setup-bit-cli - name: Create project with bit new - run: bit new TodoSample --seedPassword 12345678 --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust + run: bit new TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage S3 --notification --captcha reCaptcha --signalR --ads --multitenant false --template-package "${{ env.BIT_TEMPLATE_PACKAGE }}" --yes --platforms ios --no-build --no-format --no-migration --no-trust - name: Translate .resx files run: | diff --git a/src/Cli/Bit.Cli/Commands/DiffCommand.cs b/src/Cli/Bit.Cli/Commands/DiffCommand.cs index ef3ba037d20..1cc869f936f 100644 --- a/src/Cli/Bit.Cli/Commands/DiffCommand.cs +++ b/src/Cli/Bit.Cli/Commands/DiffCommand.cs @@ -11,9 +11,9 @@ public static Command Create(Func services) { var fromArgument = new Argument("from") { Description = "The bit Boilerplate version the project comes from. Default: the one its README.md names.", Arity = ArgumentArity.ZeroOrOne }; var toArgument = new Argument("to") { Description = $"The version to compare it with. Default: this bit's own, {BuildInfo.Version}.", Arity = ArgumentArity.ZeroOrOne }; - var projectOption = new Option("--project") { Description = "The project whose README.md gives the name, version, options and seeded accounts' password. Default: the current folder." }; + var projectOption = new Option("--project") { Description = "The project whose README.md gives the name, version and options. Default: the current folder. It isn't changed." }; var nameOption = new Option("--name") { Description = "The name to create both versions with. Default: the project's own." }; - var outputOption = new Option("--output", "-o") { Description = "The folder of the git repository both versions are committed to. Default: ~/.bitplatform/diffs/--." }; + var outputOption = new Option("--output", "-o") { Description = "The folder of the git repository both versions are committed to. Default: /bit-diff/--." }; var templatePackageOption = new Option("--template-package") { Description = "Compare with a local Bit.Boilerplate .nupkg, or the template's folder in a bitplatform checkout." }; var command = new Command("diff", "Show what changed in bit Boilerplate between two versions: creates the project with each into one git repository, so the second commit is the upgrade.") diff --git a/src/Cli/Bit.Cli/Commands/DoctorCommand.cs b/src/Cli/Bit.Cli/Commands/DoctorCommand.cs index f0faf6e42c9..604f8747929 100644 --- a/src/Cli/Bit.Cli/Commands/DoctorCommand.cs +++ b/src/Cli/Bit.Cli/Commands/DoctorCommand.cs @@ -45,7 +45,7 @@ public static Command Create(Func services) var needs = new ToolNeeds { Aspire = aspire, - ContainersRequired = aspire && (projectName is null || ProjectContext.AppHostNeedsContainers(Path.Combine(cli.Environment.CurrentDirectory, "src", "Server", $"{projectName}.Server.AppHost"))), + ContainersRequired = aspire && projectName is not null && ProjectContext.AppHostNeedsContainers(Path.Combine(cli.Environment.CurrentDirectory, "src", "Server", $"{projectName}.Server.AppHost")), NativeWebAssembly = projectName is not null && ProjectContext.UsesNativeWebAssembly(cli.Environment.CurrentDirectory), Platforms = platforms, Ide = IdeLocator.FindAll(cli.Environment, cli.Runner).FirstOrDefault()?.Id, diff --git a/src/Cli/Bit.Cli/Projects/NewWorkflow.cs b/src/Cli/Bit.Cli/Projects/NewWorkflow.cs index 52e53ba1129..e10f2cfa5ef 100644 --- a/src/Cli/Bit.Cli/Projects/NewWorkflow.cs +++ b/src/Cli/Bit.Cli/Projects/NewWorkflow.cs @@ -410,13 +410,14 @@ await steps.RunAsync("create", $"Creating {context.Name}", async (progress, ct) { var manifest = TemplateSource.ReadManifest(package.PackagePath) ?? selection.Manifest; var arguments = selection.ToTemplateArguments().Concat(request.ExtraTemplateArguments).ToList(); - context.SeedPassword = SeedPassword(manifest, selection, arguments); var result = await source.CreateAsync(package, manifest, context.Name, context.Directory, arguments, progress, ct); if (result.Succeeded && context.Exists is false) return StepResult.Failed($"Couldn't create {context.Name}", "dotnet new finished without creating the solution", hint: DiagnosticCodes.FirstErrorLine(result.Output)); var legacySolution = Path.ChangeExtension(context.Solution, ".sln"); + var readme = Path.Combine(context.Directory, "README.md"); + context.SeedPassword = File.Exists(readme) ? TemplateDiff.ReadReadme(File.ReadAllText(readme)).SeedPassword : null; if (result.Succeeded && File.Exists(context.Solution) && File.Exists(legacySolution)) { @@ -428,24 +429,6 @@ await steps.RunAsync("create", $"Creating {context.Name}", async (progress, ct) }, cancellationToken); } - public static string? SeedPassword(TemplateManifest manifest, TemplateSelection selection, List arguments) - { - if (manifest.Find("seedPassword") is null) - return null; - - var given = arguments.FindIndex(a => string.Equals(a, "--seedPassword", StringComparison.OrdinalIgnoreCase)); - - if (given >= 0 && given + 1 < arguments.Count) - return arguments[given + 1]; - - if (selection.HasExplicitValue("seedPassword")) - return selection["seedPassword"]; - - var password = $"{System.Security.Cryptography.RandomNumberGenerator.GetInt32(10_000_000, 100_000_000)}"; - arguments.AddRange(["--seedPassword", password]); - return password; - } - private void TagTelemetry(TemplateSelection selection, IReadOnlySet platforms, IReadOnlyList tools, string ide, NewRequest request) { foreach (var parameter in selection.Manifest.Parameters.Where(p => p.Type is not TemplateParameterType.String)) diff --git a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs index c7468f90a2a..d5c2fb5655a 100644 --- a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs +++ b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs @@ -56,8 +56,7 @@ public async Task RunAsync(DiffRequest request, CancellationToken cancellat options[option] = value; } - var seedPassword = options.Remove("seedPassword", out var given) ? given : record.SeedPassword; - var output = Path.GetFullPath(request.Output ?? Path.Combine(cli.Environment.BitDirectory, "diffs", $"{name}-{from}-{to}"), cli.Environment.CurrentDirectory); + var output = Path.GetFullPath(request.Output ?? Path.Combine(Path.GetTempPath(), "bit-diff", $"{name}-{from}-{to}"), cli.Environment.CurrentDirectory); if (Directory.Exists(output) && Directory.EnumerateFileSystemEntries(output).Any()) { @@ -90,7 +89,7 @@ await steps.RunAsync("git", "Creating a git repository", async (_, ct) => if (steps.AnyFailed is false) { - await SnapshotAsync(steps, "from", source, from, null, name, output, options, seedPassword, cancellationToken); + await SnapshotAsync(steps, "from", source, from, null, name, output, options, cancellationToken); } if (steps.AnyFailed is false) @@ -107,14 +106,15 @@ await steps.RunAsync("clear", $"Clearing bit Boilerplate {from}", (_, _) => if (steps.AnyFailed is false) { - await SnapshotAsync(steps, "to", source, to, request.TemplatePackage, name, output, options, seedPassword, cancellationToken); + await SnapshotAsync(steps, "to", source, to, request.TemplatePackage, name, output, options, cancellationToken); } if (steps.AnyFailed) return CliApp.ExitFailed; - var patch = output.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + ".patch"; + var patch = Path.Combine(output, "bit-diff.patch"); await Git(output, ["diff", "--binary", $"--output={patch}", "HEAD~1", "HEAD"], cancellationToken); + File.AppendAllText(Path.Combine(output, ".git", "info", "exclude"), "\n/bit-diff.patch\n"); var stat = (await Git(output, ["diff", "--shortstat", "HEAD~1", "HEAD"], cancellationToken)).Output.Trim(); cli.Console.Out.WriteLine(); @@ -124,13 +124,14 @@ await steps.RunAsync("clear", $"Clearing bit Boilerplate {from}", (_, _) => cli.Console.Out.MarkupLine($" [bold]Patch[/] {Markup.Escape(patch)}"); cli.Console.Out.MarkupLine($" [bold]Files[/] git -C {Markup.Escape(ProcessSpec.Quote(output))} diff --stat HEAD~1"); cli.Console.Out.WriteLine(); - cli.Console.Out.MarkupLine(" [grey]Both were created with the same name, options and seeded accounts' password. The ports, GUIDs and dates the template picks on every run, and the app certificate bit new replaces, still differ between them.[/]"); + cli.Console.Out.MarkupLine(" [grey]Both have the same name and options. What the template picks at random on every run, such as ports, GUIDs, dates and the seeded accounts' password, differs between them, and so does what bit new adds.[/]"); + cli.Console.Out.MarkupLine($" [grey]Delete it when you're done: bit remove {Markup.Escape(ProcessSpec.Quote(output))} --yes[/]"); cli.Console.Out.WriteLine(); return CliApp.ExitOk; } - private async Task SnapshotAsync(StepRunner steps, string id, TemplateSource source, string version, string? packagePath, string name, string output, IReadOnlyDictionary options, string? seedPassword, CancellationToken cancellationToken) + private async Task SnapshotAsync(StepRunner steps, string id, TemplateSource source, string version, string? packagePath, string name, string output, IReadOnlyDictionary options, CancellationToken cancellationToken) { await steps.RunAsync($"create-{id}", $"Creating {name} with bit Boilerplate {version}", async (progress, ct) => { @@ -140,7 +141,7 @@ await steps.RunAsync($"create-{id}", $"Creating {name} with bit Boilerplate {ver return StepResult.FromProcess(installed.Succeeded ? installed with { ExitCode = 1 } : installed, "", $"Couldn't get bit Boilerplate {version}", packagePath is null ? $"dotnet new install Bit.Boilerplate::{version}" : null); var manifest = TemplateSource.ReadManifest(package.PackagePath) ?? TemplateManifest.Embedded; - var (arguments, unknown) = TemplateArguments(manifest, options, seedPassword); + var (arguments, unknown) = TemplateArguments(manifest, options); var created = await source.CreateAsync(package, manifest, name, output, arguments, progress, ct); if (created.Succeeded is false) @@ -162,7 +163,7 @@ await steps.RunAsync($"create-{id}", $"Creating {name} with bit Boilerplate {ver }, cancellationToken); } - public static (List Arguments, List Unknown) TemplateArguments(TemplateManifest manifest, IReadOnlyDictionary options, string? seedPassword) + public static (List Arguments, List Unknown) TemplateArguments(TemplateManifest manifest, IReadOnlyDictionary options) { var arguments = new List(); var unknown = new List(); @@ -178,11 +179,6 @@ public static (List Arguments, List Unknown) TemplateArguments(T arguments.AddRange([$"--{parameter.Name}", normalized]); } - if (seedPassword is not null && manifest.Find("seedPassword") is not null) - { - arguments.AddRange(["--seedPassword", seedPassword]); - } - return (arguments, unknown); } diff --git a/src/Cli/Bit.Cli/Templates/TemplateLabels.cs b/src/Cli/Bit.Cli/Templates/TemplateLabels.cs index cf87b8b389f..8b7f0cf8fda 100644 --- a/src/Cli/Bit.Cli/Templates/TemplateLabels.cs +++ b/src/Cli/Bit.Cli/Templates/TemplateLabels.cs @@ -25,8 +25,7 @@ public static class TemplateLabels ["theme"] = "Theme", ["apiServerUrl"] = "API server URL", ["webAppUrl"] = "Web app URL", - ["advancedTests"] = "Advanced automated tests", - ["seedPassword"] = "Seeded accounts' password" + ["advancedTests"] = "Advanced automated tests" }; public static string For(TemplateParameter parameter) diff --git a/src/Cli/README.md b/src/Cli/README.md index 213b5457ef3..60341eefdf7 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -71,7 +71,6 @@ Every option of the bit Boilerplate template works the same way: | `--advancedTests` | `true`, `false` | `false` | | `--apiServerUrl` | a URL | | | `--webAppUrl` | a URL | | -| `--seedPassword` | the seeded accounts' password | a random 8 digit number, shown at the end and in the project's README | And these of its own: @@ -113,11 +112,11 @@ bit diff 10.6.2 10.7.0 bit diff --template-package ../bitplatform/src/Templates/Boilerplate/Bit.Boilerplate ``` -- It creates the project with each version, the old one first, into one git repository, so the second commit is exactly the upgrade, and writes it as a patch file next to the repository too. -- In a project, it takes the name, the version, the options and the seeded accounts' password from the project's `README.md`, where `bit new` records them. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. +- It creates the project with each version, the old one first, into one git repository in the temp folder, so the second commit is exactly the upgrade, and writes it as `bit-diff.patch` in that repository too. Your project isn't touched. +- In a project, it takes the name, the version and the options from the project's `README.md`, where the template records them. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. - An option a version doesn't have is left out for that version, and said so. -- The ports, GUIDs and dates the template picks on every run still differ between the two, and so do the app certificate and the formatting `bit new` adds. -- The repository goes to `~/.bitplatform/diffs/--`, or `-o `. +- What the template picks at random on every run, like ports, GUIDs, dates and the seeded accounts' password, differs between the two, and so does what `bit new` adds after creating a project. +- The repository goes to `/bit-diff/--`, or `-o `. Delete it with `bit remove --yes` when you're done. The project's `upgrade` skill, for the AI coding tools, runs it and applies the changes to your code. diff --git a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs index 35ff2e2088c..69c7f01abea 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs @@ -52,32 +52,9 @@ public void EachVersion_Should_GetOnlyTheOptionsItHas() { var options = new Dictionary { ["database"] = "postgresql", ["madeUp"] = "true", ["module"] = "Nope" }; - var (arguments, unknown) = TemplateDiff.TemplateArguments(TemplateManifest.Embedded, options, "40817263"); + var (arguments, unknown) = TemplateDiff.TemplateArguments(TemplateManifest.Embedded, options); - CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL", "--seedPassword", "40817263" }, arguments); + CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL" }, arguments); CollectionAssert.AreEquivalent(new[] { "--madeUp true", "--module Nope" }, unknown); - - var older = TemplateManifest.Parse("""{ "identity": "Bit.Boilerplate", "shortName": "bit-bp", "sourceName": "Boilerplate", "symbols": { "database": { "type": "parameter", "datatype": "choice", "defaultValue": "Sqlite", "choices": [ { "choice": "Sqlite" }, { "choice": "PostgreSQL" } ] } } }"""); - CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL" }, TemplateDiff.TemplateArguments(older, options, "40817263").Arguments, "A version without the seeded accounts' password option has 123456 written into it."); - } - - [TestMethod] - public void TheSeedPassword_Should_BeRandomUnlessGiven() - { - var manifest = TemplateManifest.Embedded; - var arguments = new List(); - - var password = NewWorkflow.SeedPassword(manifest, new TemplateSelection(manifest), arguments); - - Assert.IsNotNull(password); - StringAssert.Matches(password, new System.Text.RegularExpressions.Regex(@"^\d{8}$")); - CollectionAssert.AreEqual(new[] { "--seedPassword", password }, arguments); - - var selection = new TemplateSelection(manifest); - selection.Set("seedPassword", "11112222"); - var given = new List(selection.ToTemplateArguments()); - - Assert.AreEqual("11112222", NewWorkflow.SeedPassword(manifest, selection, given)); - Assert.AreEqual(1, given.Count(a => a is "--seedPassword")); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md index 63e92e8bfc9..ebd9e98dc58 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md @@ -178,14 +178,14 @@ Everything lives in the one skill file; it brings no scripts or tools of its own **Canonical file**: `.github/agents/upgrade.agent.md` -**What it does**: Brings a newer bit Boilerplate version into the project. `bit diff` creates the project with the version it came from and with the newer one into one git repository, so the second commit is exactly what the template changed for a project with this name and these options. The skill carries each of those changes into the codebase, keeping what the team changed, adds the database migrations the changes need, and updates the version `README.md` and the MCP server configurations record. +**What it does**: Brings a newer bit Boilerplate version into the project. `bit diff` creates the project with the version it came from and with the newer one into one git repository in a temporary folder, so the second commit is exactly what the template changed for a project with this name and these options. The skill carries each of those changes into the codebase, keeping what the team changed, adds the database migrations the changes need, and updates the version `README.md` and the MCP server configurations record. **When to use it**: When a newer bit Boilerplate is out and the project should get its fixes, features and dependency updates. **Key capabilities**: - Reads the project's version and options from `README.md`, and checks them against the code before diffing - Treats the old version as the common ancestor: takes files the team never changed, merges files both changed, and leaves out what no longer has anything to attach to -- Knows which differences are artifacts of generating a project (ports, GUIDs, dates, the app certificate) rather than upgrades +- Knows which differences come from generating a project (ports, GUIDs, dates, the seeded accounts' password, the app certificate) rather than from the upgrade - Adds a migration for every database context whose model changed, instead of editing existing ones - Asks only about decisions that change how the app behaves - Proves the result: build, formatting, tests, and the app running diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md index 71502a8ae53..aa387dcc17d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md @@ -19,8 +19,8 @@ The work is yours to judge. Ask the user only what the code can't answer and wha ## 2. Get the upstream changes -- Run `bit diff` in the project folder, with the from and to versions when they aren't the defaults and with any option whose recorded value no longer matches the project. It creates the project with each version into one git repository, so its second commit is exactly what the template changed for a project with this name and these options, and it writes that commit as a patch file. Its output says where both are. -- Both versions are created with this project's name and its seeded accounts' password, so those don't show up as changes. Some differences are still not upgrades and stay as they are in the project: the ports, GUIDs and dates the template picks on every run, the app certificate, the formatting `bit new` applies, and EF Core migrations, which neither version has. +- Run `bit diff` from the project folder, with the from and to versions when they aren't the defaults and with any option whose recorded value no longer matches the project. It only reads the project's `README.md`; it creates the project with each version in a temporary folder of its own, into one git repository, so the second commit is exactly what the template changed for a project with this name and these options, and it writes that commit as a patch file there too. Its output says where. +- Some differences come from generating a project, not from the upgrade, and stay as they are in this project: everything the template picks at random on every run, such as ports, GUIDs like the user secrets id, dates and the seeded accounts' password, plus the app certificate, the formatting `bit new` applies, and EF Core migrations, which neither version has. - Read the whole change set before touching anything: which areas it touches, which files are new, deleted or renamed, and which changes belong together, such as a package bump and the code that needs it. ## 3. Bring each change in @@ -63,3 +63,5 @@ Tell the user, briefly: - what was left out and why; - the decisions that are theirs, and the migrations, settings and secrets that need their attention; - that nothing is merged or pushed until they say so. + +Delete the diff's folder when you're done, with `bit remove` and the folder `bit diff` named, so it doesn't take up space. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json index cd5f0e11614..a9cadfdd819 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json @@ -79,10 +79,6 @@ { "id": "advancedTests", "isVisible": false - }, - { - "id": "seedPassword", - "isVisible": false } ] } \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index 512c3143103..17cc4a0b79a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -40,26 +40,12 @@ "format": "yyyy-MM-dd" } }, - "seedPassword": { - "displayName": "Seeded accounts' password", - "type": "parameter", - "datatype": "string", - "description": "The password of test@bitplatform.dev and the other accounts the database starts with. Default: a random 8 digit number." - }, - "seedPasswordRandom": { + "SeedPassword": { "type": "generated", "generator": "random", "parameters": { "low": 10000000, "high": 99999999 - } - }, - "seedPasswordValue": { - "type": "generated", - "generator": "coalesce", - "parameters": { - "sourceVariableName": "seedPassword", - "fallbackVariableName": "seedPasswordRandom" }, "replaces": "12345678" }, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs index b3a9c7b4434..a0e6023035a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs @@ -35,6 +35,7 @@ protected override async Task SendAsync(HttpRequestMessage serverCommunicationSuccess = true; + // The caller reads these responses itself, e.g. /healthz's report that comes with a 503. if (request.Options.TryGetValue(new HttpRequestOptionsKey(RequestOptionNames.AllowedStatusCodes), out var allowedStatusCodes) && allowedStatusCodes.Contains(response.StatusCode)) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/Extensions/PlaywrightPageExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/Extensions/PlaywrightPageExtensions.cs index 05941b5c699..50ca4a6954a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/Extensions/PlaywrightPageExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/Extensions/PlaywrightPageExtensions.cs @@ -5,7 +5,6 @@ public static class PlaywrightPageExtensions { extension(IPage page) { - //#if (advancedTests == true) /// /// Navigates inside a running app without reloading the page, by posting its own NAVIGATE_TO message (see /// events.ts) - which AppClientCoordinator turns into a NavigationManager.NavigateTo, @@ -33,7 +32,6 @@ await page.Locator("main .main-container").First await page.WaitForLoadStateAsync(LoadState.NetworkIdle); } - //#endif /// /// Waits until Blazor's interactive renderer has attached to the document. /// @@ -68,7 +66,6 @@ await page.WaitForFunctionAsync( } } } - //#if (advancedTests == true) // The exact message shape events.ts listens for; it hands anything with this key to App.publishMessage. private const string publishNavigateToScript = $$""" @@ -76,5 +73,4 @@ await page.WaitForFunctionAsync( { key: 'PUBLISH_MESSAGE', message: '{{ClientAppMessages.NAVIGATE_TO}}', payload: route }, window.location.origin) """; - //#endif } From ba96ea4db8704204d9578c2ade069d0bf91db64c Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:11:18 +0200 Subject: [PATCH 05/12] fix(templates): salt the seed hash per project, bit installs the C# extension only #13974 --- src/Cli/Bit.Cli/Projects/ProjectSteps.cs | 16 +++++++++++++++- src/Cli/README.md | 4 ++-- src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs | 12 ++++++++++++ .../Identity/Configurations/UserConfiguration.cs | 8 ++++---- .../20261018233101_Initial.Designer.cs | 6 +++--- .../Data/Migrations/20261018233101_Initial.cs | 6 +++--- .../Data/Migrations/AppDbContextModelSnapshot.cs | 6 +++--- 7 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs index 680add32440..2eda06f3a7f 100644 --- a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs +++ b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs @@ -716,7 +716,7 @@ public async Task PlaywrightAsync(Action progress, Cancellat public async Task VsCodeExtensionsAsync(Action progress, CancellationToken cancellationToken) { - var recommended = ReadRecommendedExtensions(project.Directory); + var recommended = ExtensionsToInstall(ReadRecommendedExtensions(project.Directory)); if (recommended.Count == 0) return StepResult.Skipped("Skipped VS Code extensions", "the project recommends none"); @@ -754,6 +754,20 @@ public async Task VsCodeExtensionsAsync(Action progress, Can $"{ProcessSpec.Quote(code.Executable)} {string.Join(' ', arguments)}", names); } + private static readonly string[] visualStudioLicensedExtensions = ["ms-dotnettools.csdevkit", "ms-dotnettools.dotnet-maui"]; + + public static IReadOnlyList ExtensionsToInstall(IReadOnlyList recommended) + { + List extensions = [.. recommended.Where(id => visualStudioLicensedExtensions.Contains(id, StringComparer.OrdinalIgnoreCase) is false)]; + + if (extensions.Count < recommended.Count && extensions.Contains("ms-dotnettools.csharp", StringComparer.OrdinalIgnoreCase) is false) + { + extensions.Insert(0, "ms-dotnettools.csharp"); + } + + return extensions; + } + public static IReadOnlyList ReadRecommendedExtensions(string projectDirectory) { var path = Path.Combine(projectDirectory, ".vscode", "extensions.json"); diff --git a/src/Cli/README.md b/src/Cli/README.md index 60341eefdf7..f5da4e796eb 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -29,7 +29,7 @@ It needs a .NET 10 SDK or later. `dnx` asks once before it downloads the package 8. Adds the `Initial` EF Core migration, and commits it on its own. The app applies migrations when it starts. 9. With `--github-repo`, signs you in to GitHub in your browser when needed, creates a private repository named after the project, pushes `develop` and `main` to it, and makes `develop` its default branch. 10. Marks the folder as trusted for VS Code, Claude Code, Copilot CLI, Codex and Gemini CLI, so the project's tasks and MCP servers work without prompts. That works even when VS Code was just installed and has never run. -11. Installs the VS Code extensions the project recommends, like C# Dev Kit and Claude Code. Copilot is built into VS Code, so it isn't one of them. +11. Installs the VS Code extensions the project recommends, like the C# extension and Claude Code. C# Dev Kit and the .NET MAUI extension stay recommended, so VS Code offers them, but bit doesn't install them: they come with Visual Studio's license, which is free for teams of up to five. Copilot is built into VS Code, so it isn't one of them. 12. Starts the project: with Aspire, `aspire start`; without it, `dotnet run` of Server.Web (and of Server.Api when the API is standalone). It waits until the servers are healthy, comes last so everything else is ready however long it takes, and while it waits, shows which resources are still starting or unhealthy, and stops waiting as soon as one fails to start. It asks `aspire doctor` first whether Aspire can use Docker or Podman. Without one, a project whose servers wait for a container (SQL Server, PostgreSQL, MySQL or Redis) isn't started, with why and how to fix it; any other project starts without its containers, so only Keycloak, Mailpit and the like are missing. 13. Opens the project in VS Code, or the IDE you pick. All that's left is signing in to Claude or Copilot. 14. Opens the Aspire dashboard and then the app in your browser, and keeps the project running while the terminal you ran bit in is open. Close it, or press Ctrl+C, to stop the project. Without a terminal, such as under a coding agent or in a dev container, it only starts the project once with Aspire, so the container images are pulled, and stops it; CI starts nothing. @@ -273,7 +273,7 @@ Without a map, the newest `obj/**/bit-minifier.map` under the current folder is - **Tools** you tick, with `winget` on Windows, Homebrew on macOS and the distribution's package manager on Linux. Each command is shown before it runs. - **The .NET SDK** a project pins, next to the SDKs you already have: with `winget` on Windows (Microsoft's `dotnet-install.ps1` when winget doesn't list that build yet), Microsoft's installer package on macOS after checking it's signed by Microsoft, and Microsoft's `dotnet-install.sh` on Linux. -- **VS Code extensions** the project's `.vscode/extensions.json` recommends, only the missing ones, with `code --install-extension`. +- **VS Code extensions** the project's `.vscode/extensions.json` recommends, only the missing ones and never C# Dev Kit or the .NET MAUI extension, with `code --install-extension`. - **Playwright's Chromium**, in Playwright's own browser folder, with the driver the project's tests were built with. - **A container runtime**, when you tick it: on Windows, Podman for your user only, its WSL machine, and a sign-in entry under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` that starts that machine; on macOS, Colima, the docker CLI and buildx, with Colima started at sign-in by `brew services`; on Linux, Docker Engine. - **Container images** the project's AppHost uses, which Docker or Podman pulls during the first Aspire start and keeps. diff --git a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs index f0fea638ef8..19b59362dbb 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs @@ -630,6 +630,18 @@ public void RecommendedExtensions_Should_BeReadDespiteCommentsAndRepeats() CollectionAssert.AreEqual(new[] { "ms-dotnettools.csdevkit", "Anthropic.claude-code" }, ProjectSteps.ReadRecommendedExtensions(project.Directory).ToArray()); } + [TestMethod] + public void CSharpDevKitAndMaui_Should_StayRecommendedButNotBeInstalled() + { + CollectionAssert.AreEqual(new[] { "ms-dotnettools.csharp", "Anthropic.claude-code" }, + ProjectSteps.ExtensionsToInstall(["ms-dotnettools.csdevkit", "Anthropic.claude-code", "ms-dotnettools.dotnet-maui"]).ToArray()); + + CollectionAssert.AreEqual(new[] { "Anthropic.claude-code", "ms-dotnettools.csharp" }, + ProjectSteps.ExtensionsToInstall(["Anthropic.claude-code", "ms-dotnettools.csharp", "ms-dotnettools.csdevkit"]).ToArray()); + + CollectionAssert.AreEqual(new[] { "Anthropic.claude-code" }, ProjectSteps.ExtensionsToInstall(["Anthropic.claude-code"]).ToArray()); + } + [TestMethod] public async Task ExtensionsVsCodeBuildsIn_Should_CountAsInstalled() { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs index 2086935aba6..f34cebc0a70 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Features/Identity/Configurations/UserConfiguration.cs @@ -6,12 +6,12 @@ namespace Boilerplate.Server.Api.Features.Identity.Configurations; public partial class UserConfiguration : IEntityTypeConfiguration { - private static readonly string seedPasswordHash = HashSeedPassword("12345678"); + private static readonly string seedPasswordHash = HashSeedPassword("12345678", Guid.Parse("AC87AA5B-4B37-4E52-8468-2D5DF24AF256")); - /// Identity's V3 format with a fixed salt, so the model stays the same on every run. - private static string HashSeedPassword(string password) + /// Identity's V3 format, salted with the project's own GUID, so the model stays the same on every run. + private static string HashSeedPassword(string password, Guid projectId) { - var salt = SHA256.HashData(Encoding.UTF8.GetBytes(password))[..16]; + var salt = projectId.ToByteArray(); var subkey = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA512, 32); var hash = new byte[13 + salt.Length + subkey.Length]; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs index e5eda8aa909..ad303ba8d17 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.Designer.cs @@ -405,7 +405,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "TEST@BITPLATFORM.DEV", NormalizedUserName = "TEST", - PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", + PasswordHash = "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", PhoneNumber = "+31684207362", PhoneNumberConfirmed = true, SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", @@ -428,7 +428,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-ADMIN@BITPLATFORM.DEV", NormalizedUserName = "STORE-ADMIN", - PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", + PasswordHash = "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", PhoneNumberConfirmed = false, SecurityStamp = "869ff4a9-4b07-4cc1-8141-c5fc033daf82", TwoFactorEnabled = false, @@ -450,7 +450,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-USER@BITPLATFORM.DEV", NormalizedUserName = "STORE-USER", - PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", + PasswordHash = "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", PhoneNumberConfirmed = false, SecurityStamp = "469ff4a9-4b07-4cc1-8141-c5fc033daf84", TwoFactorEnabled = false, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs index 791fb16bc65..3c2618b43ed 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/20261018233101_Initial.cs @@ -714,9 +714,9 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Id", "AccessFailedCount", "BirthDate", "ConcurrencyStamp", "CreatedOn", "ElevatedAccessTokenRequestedOn", "Email", "EmailConfirmed", "EmailTokenRequestedOn", "FullName", "Gender", "HasProfilePicture", "LockoutEnabled", "LockoutEnd", "NormalizedEmail", "NormalizedUserName", "OtpRequestedOn", "PasswordHash", "PhoneNumber", "PhoneNumberConfirmed", "PhoneNumberTokenRequestedOn", "ResetPasswordTokenRequestedOn", "SecurityStamp", "TwoFactorEnabled", "TwoFactorTokenRequestedOn", "UserName" }, values: new object[,] { - { new Guid("4ff71671-a1d6-4f97-abb9-d87d7b47d6e4"), 0, 1306790461440000000L, "435e1a26-5b3a-4544-8e91-2760cd28e229", 0L, null, "store-user@bitplatform.dev", true, 1306790461440000000L, "Store tenant user", 0, false, true, null, "STORE-USER@BITPLATFORM.DEV", "STORE-USER", null, "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", null, false, null, null, "469ff4a9-4b07-4cc1-8141-c5fc033daf84", false, null, "store-user" }, - { new Guid("6ff71671-a1d6-4f97-abb9-d87d7b47d6e5"), 0, 1306790461440000000L, "425e1a26-5b3a-4544-8e91-2760cd28e230", 0L, null, "store-admin@bitplatform.dev", true, 1306790461440000000L, "Store tenant admin", 0, false, true, null, "STORE-ADMIN@BITPLATFORM.DEV", "STORE-ADMIN", null, "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", null, false, null, null, "869ff4a9-4b07-4cc1-8141-c5fc033daf82", false, null, "store-admin" }, - { new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), 0, 1306790461440000000L, "315e1a26-5b3a-4544-8e91-2760cd28e231", 0L, null, "test@bitplatform.dev", true, 1306790461440000000L, "Boilerplate test account", 0, false, true, null, "TEST@BITPLATFORM.DEV", "TEST", null, "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", "+31684207362", true, null, null, "959ff4a9-4b07-4cc1-8141-c5fc033daf83", false, null, "test" } + { new Guid("4ff71671-a1d6-4f97-abb9-d87d7b47d6e4"), 0, 1306790461440000000L, "435e1a26-5b3a-4544-8e91-2760cd28e229", 0L, null, "store-user@bitplatform.dev", true, 1306790461440000000L, "Store tenant user", 0, false, true, null, "STORE-USER@BITPLATFORM.DEV", "STORE-USER", null, "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", null, false, null, null, "469ff4a9-4b07-4cc1-8141-c5fc033daf84", false, null, "store-user" }, + { new Guid("6ff71671-a1d6-4f97-abb9-d87d7b47d6e5"), 0, 1306790461440000000L, "425e1a26-5b3a-4544-8e91-2760cd28e230", 0L, null, "store-admin@bitplatform.dev", true, 1306790461440000000L, "Store tenant admin", 0, false, true, null, "STORE-ADMIN@BITPLATFORM.DEV", "STORE-ADMIN", null, "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", null, false, null, null, "869ff4a9-4b07-4cc1-8141-c5fc033daf82", false, null, "store-admin" }, + { new Guid("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), 0, 1306790461440000000L, "315e1a26-5b3a-4544-8e91-2760cd28e231", 0L, null, "test@bitplatform.dev", true, 1306790461440000000L, "Boilerplate test account", 0, false, true, null, "TEST@BITPLATFORM.DEV", "TEST", null, "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", "+31684207362", true, null, null, "959ff4a9-4b07-4cc1-8141-c5fc033daf83", false, null, "test" } }); migrationBuilder.InsertData( diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 4f3afbf7585..0ed75675ad1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -402,7 +402,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "TEST@BITPLATFORM.DEV", NormalizedUserName = "TEST", - PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", + PasswordHash = "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", PhoneNumber = "+31684207362", PhoneNumberConfirmed = true, SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", @@ -425,7 +425,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-ADMIN@BITPLATFORM.DEV", NormalizedUserName = "STORE-ADMIN", - PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", + PasswordHash = "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", PhoneNumberConfirmed = false, SecurityStamp = "869ff4a9-4b07-4cc1-8141-c5fc033daf82", TwoFactorEnabled = false, @@ -447,7 +447,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) LockoutEnabled = true, NormalizedEmail = "STORE-USER@BITPLATFORM.DEV", NormalizedUserName = "STORE-USER", - PasswordHash = "AQAAAAIAAYagAAAAEO95fIEY8C37ZJYH3V0/jHYY2aibHkTyj1LloMeAfT/Xo/ZapvUVGB74imP0L4JAMg==", + PasswordHash = "AQAAAAIAAYagAAAAEFuqh6w3S1JOhGgtXfJK8lagE8kg3CNNj/8RqjIksya+UXhqDL/5ehmXfOSiGJfz/w==", PhoneNumberConfirmed = false, SecurityStamp = "469ff4a9-4b07-4cc1-8141-c5fc033daf84", TwoFactorEnabled = false, From 582fc7d4d3c4ac6f3fa1a33f197194130048ff39 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:26:30 +0200 Subject: [PATCH 06/12] fix(templates): passwords need at least 8 characters #13974 --- .../src/Server/Boilerplate.Server.Api/appsettings.json | 2 +- .../src/Server/Boilerplate.Server.Web/appsettings.json | 2 +- .../Shared/Features/Identity/Dtos/ChangePasswordRequestDto.cs | 4 ++-- .../Shared/Features/Identity/Dtos/ResetPasswordRequestDto.cs | 2 +- .../src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs | 2 +- .../Features/Identity/UnconfirmedAccountPasswordTests.cs | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json index ce0e8014310..a0b8be8c81b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json @@ -104,7 +104,7 @@ "MaxPrivilegedSessionsCount_Comment": "Is the maximum number of concurrent privileged sessions a user can have.", "Password": { "RequireDigit": "false", - "RequiredLength": "6", + "RequiredLength": "8", "RequireNonAlphanumeric": "false", "RequireUppercase": "false", "RequireLowercase": "false" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json index 285c55c0eac..b81718beb03 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json @@ -24,7 +24,7 @@ "MaxPrivilegedSessionsCount": 3, "Password": { "RequireDigit": "false", - "RequiredLength": "6", + "RequiredLength": "8", "RequireNonAlphanumeric": "false", "RequireUppercase": "false", "RequireLowercase": "false" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ChangePasswordRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ChangePasswordRequestDto.cs index 0b92702a55e..8fb439de2f6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ChangePasswordRequestDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ChangePasswordRequestDto.cs @@ -4,12 +4,12 @@ namespace Boilerplate.Shared.Features.Identity.Dtos; public partial class ChangePasswordRequestDto { [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] - [MinLength(6, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] + [MinLength(8, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] [Display(Name = nameof(AppStrings.OldPassword))] public string? OldPassword { get; set; } [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] - [MinLength(6, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] + [MinLength(8, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] [Display(Name = nameof(AppStrings.NewPassword))] public string? NewPassword { get; set; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ResetPasswordRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ResetPasswordRequestDto.cs index 52fe6fe0d7b..7faf20462db 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ResetPasswordRequestDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/ResetPasswordRequestDto.cs @@ -9,7 +9,7 @@ public partial class ResetPasswordRequestDto : IdentityRequestDto public string? Token { get; set; } [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] - [MinLength(6, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] + [MinLength(8, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] [Display(Name = nameof(AppStrings.Password))] public string? Password { get; set; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs index 6905e294b47..3f3a4fe19be 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Features/Identity/Dtos/SignUpRequestDto.cs @@ -11,7 +11,7 @@ public partial class SignUpRequestDto : IdentityRequestDto /// "12345678" [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] - [MinLength(6, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] + [MinLength(8, ErrorMessage = nameof(AppStrings.MinLengthAttribute_ValidationError))] [Display(Name = nameof(AppStrings.Password))] public string? Password { get; set; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UnconfirmedAccountPasswordTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UnconfirmedAccountPasswordTests.cs index 8dbb1c2384c..fab26e0b59f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UnconfirmedAccountPasswordTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/Identity/UnconfirmedAccountPasswordTests.cs @@ -13,8 +13,8 @@ namespace Boilerplate.Tests.Features.Identity; [TestClass, TestCategory("IntegrationTest")] public class UnconfirmedAccountPasswordTests { - private const string AttackerPassword = "hack123"; - private const string OwnerPassword = "ali456"; + private const string AttackerPassword = "hack1234"; + private const string OwnerPassword = "alice456"; public TestContext TestContext { get; set; } = default!; From b1b1af5823d1167f3d17779830ebf1c6926863c8 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:30:46 +0200 Subject: [PATCH 07/12] feat(templates): bit.json records the version and options instead of README.md #13974 --- .github/agents/test-platform.md | 4 +- src/Cli/Bit.Cli/Commands/DiffCommand.cs | 4 +- src/Cli/Bit.Cli/Projects/TemplateDiff.cs | 52 ++++++++++- src/Cli/README.md | 2 +- src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs | 27 ++++++ .../.docs/18- Available Agent Skills.md | 4 +- .../.docs/19- Project Miscellaneous Files.md | 28 ++---- .../.github/agents/upgrade.agent.md | 4 +- .../.template.config/template.json | 20 +++++ .../Bit.Boilerplate/Boilerplate.sln | 1 + .../Bit.Boilerplate/Boilerplate.slnx | 1 + .../Boilerplate/Bit.Boilerplate/README.md | 86 ------------------- .../Boilerplate/Bit.Boilerplate/bit.json | 27 ++++++ 13 files changed, 141 insertions(+), 119 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/bit.json diff --git a/.github/agents/test-platform.md b/.github/agents/test-platform.md index 5c6f6bb5915..ef2939b4984 100644 --- a/.github/agents/test-platform.md +++ b/.github/agents/test-platform.md @@ -483,7 +483,7 @@ for it, so check it by hand, with bit and the template of the commit under test, - Run a bit built from the commit with `dotnet` on its `bit.dll`. `dotnet run --project` from a folder whose SDK is another one hands its MSBuild variables to every build bit starts, which then fail, and leave hundreds of MSBuild processes behind. -- Done means: the name, version and options come from the project's `README.md`; there are two commits named after +- Done means: the name, version and options come from the project's `bit.json`; there are two commits named after the versions and a patch file in the diff's folder; the project itself is unchanged; and the differences are the template's own changes plus only what the template picks at random on every run (ports, GUIDs, dates, the seeded accounts' password). An option the older version doesn't have is reported, not passed to it. @@ -505,7 +505,7 @@ Lead with the verdict, then: exit code, the Aspire check inside it as in item 6, and per failure its cause and fix. 6. **Aspire containers**: the commit or the nuget.org version tested, how soon each run was up, whether the second run reused every container, how long after the last run they stopped, and per failure its cause and fix. -7. **bit diff**: the versions compared, the size of the change, whether the README's values were read, any +7. **bit diff**: the versions compared, the size of the change, whether `bit.json`'s values were read, any difference that is neither an upgrade nor random per run, and per failure its cause and fix. 8. **Waiting for a CD**: every fix that only the next CD can prove, and for a library fix whether it can be tried through `prerelease_packages` before its release. diff --git a/src/Cli/Bit.Cli/Commands/DiffCommand.cs b/src/Cli/Bit.Cli/Commands/DiffCommand.cs index 1cc869f936f..43ddc455e8e 100644 --- a/src/Cli/Bit.Cli/Commands/DiffCommand.cs +++ b/src/Cli/Bit.Cli/Commands/DiffCommand.cs @@ -11,7 +11,7 @@ public static Command Create(Func services) { var fromArgument = new Argument("from") { Description = "The bit Boilerplate version the project comes from. Default: the one its README.md names.", Arity = ArgumentArity.ZeroOrOne }; var toArgument = new Argument("to") { Description = $"The version to compare it with. Default: this bit's own, {BuildInfo.Version}.", Arity = ArgumentArity.ZeroOrOne }; - var projectOption = new Option("--project") { Description = "The project whose README.md gives the name, version and options. Default: the current folder. It isn't changed." }; + var projectOption = new Option("--project") { Description = "The project whose bit.json gives the name, version and options. Default: the current folder. It isn't changed." }; var nameOption = new Option("--name") { Description = "The name to create both versions with. Default: the project's own." }; var outputOption = new Option("--output", "-o") { Description = "The folder of the git repository both versions are committed to. Default: /bit-diff/--." }; var templatePackageOption = new Option("--template-package") { Description = "Compare with a local Bit.Boilerplate .nupkg, or the template's folder in a bitplatform checkout." }; @@ -33,7 +33,7 @@ public static Command Create(Func services) var option = new Option($"--{parameter.Name}") { Arity = ArgumentArity.ZeroOrOne, - Description = $"{TemplateLabels.For(parameter)}. Default: what the project's README.md records." + Description = $"{TemplateLabels.For(parameter)}. Default: what the project's bit.json records." }; templateOptions[parameter.Name] = option; diff --git a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs index d5c2fb5655a..aefcd8e747d 100644 --- a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs +++ b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Text.Json; +using System.Text.RegularExpressions; using Bit.Cli.Infrastructure; using Bit.Cli.Templates; using Spectre.Console; @@ -31,8 +32,7 @@ public async Task RunAsync(DiffRequest request, CancellationToken cancellat NewWorkflow.WriteHeader(cli); var directory = Path.GetFullPath(request.Project ?? ".", cli.Environment.CurrentDirectory); - var readme = Path.Combine(directory, "README.md"); - var record = File.Exists(readme) ? ReadReadme(File.ReadAllText(readme)) : new ProjectRecord(null, null, new Dictionary(), null); + var record = ReadProject(directory); var name = request.Name ?? (Directory.Exists(directory) ? ProjectContext.FindProjectName(directory) : null) ?? record.Name; var from = request.From ?? record.Version; var to = request.To ?? (request.TemplatePackage is null ? BuildInfo.Version : TemplateSource.FolderVersion); @@ -41,7 +41,7 @@ public async Task RunAsync(DiffRequest request, CancellationToken cancellat return UsageError($"{directory} isn't a bit Boilerplate project. Run bit diff in the project's folder, or pass --name."); if (from is null) - return UsageError($"{readme} doesn't say which bit Boilerplate version made the project. Pass it: bit diff [to]."); + return UsageError($"{directory} has no bit.json that says which bit Boilerplate version made the project. Pass it: bit diff [to]."); if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase) && request.TemplatePackage is null) { @@ -182,6 +182,50 @@ public static (List Arguments, List Unknown) TemplateArguments(T return (arguments, unknown); } + public static ProjectRecord ReadProject(string directory) + { + var bitJson = Path.Combine(directory, "bit.json"); + + if (File.Exists(bitJson) && ReadBitJson(File.ReadAllText(bitJson)) is { } record) + return record; + + var readme = Path.Combine(directory, "README.md"); + return File.Exists(readme) ? ReadReadme(File.ReadAllText(readme)) : new ProjectRecord(null, null, new Dictionary(), null); + } + + public static ProjectRecord? ReadBitJson(string json) + { + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); + var root = document.RootElement; + + if (root.ValueKind is not JsonValueKind.Object) + return null; + + string? Text(string name) => root.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.String ? value.GetString() : null; + + var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (root.TryGetProperty("options", out var values) && values.ValueKind is JsonValueKind.Object) + { + foreach (var option in values.EnumerateObject()) + { + if (option.Value.ValueKind is JsonValueKind.String or JsonValueKind.True or JsonValueKind.False) + { + options[option.Name] = option.Value.ValueKind is JsonValueKind.String ? option.Value.GetString()! : option.Value.GetBoolean() ? "true" : "false"; + } + } + } + + return new ProjectRecord(Text("version"), Text("name"), options, null); + } + catch (JsonException) + { + return null; + } + } + public static ProjectRecord ReadReadme(string readme) { var version = VersionRegex().Match(readme) is { Success: true } versionMatch ? versionMatch.Groups["version"].Value : null; diff --git a/src/Cli/README.md b/src/Cli/README.md index f5da4e796eb..a216fd56c88 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -113,7 +113,7 @@ bit diff --template-package ../bitplatform/src/Templates/Boilerplate/Bit.Boilerp ``` - It creates the project with each version, the old one first, into one git repository in the temp folder, so the second commit is exactly the upgrade, and writes it as `bit-diff.patch` in that repository too. Your project isn't touched. -- In a project, it takes the name, the version and the options from the project's `README.md`, where the template records them. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. +- In a project, it takes the name, the version and the options from the project's `bit.json`, where the template records them; a project created before `bit.json` has them in its `README.md`. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. - An option a version doesn't have is left out for that version, and said so. - What the template picks at random on every run, like ports, GUIDs, dates and the seeded accounts' password, differs between the two, and so does what `bit new` adds after creating a project. - The repository goes to `/bit-diff/--`, or `-o `. Delete it with `bit remove --yes` when you're done. diff --git a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs index 69c7f01abea..e38962559e3 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs @@ -6,6 +6,33 @@ namespace Bit.Cli.Tests; [TestClass] public class DiffTests { + [TestMethod] + public void BitJson_Should_GiveTheVersionNameAndOptions() + { + using var host = new Infrastructure.TestHost(); + File.WriteAllText(Path.Combine(host.WorkingDirectory, "bit.json"), """ + { + "template": "bit-bp", + "version": "10.8.0", + "name": "Contoso", + "options": { "database": "PostgreSQL", "aspire": "False", "sample": true, "url": 5 } + } + """); + File.WriteAllText(Path.Combine(host.WorkingDirectory, "README.md"), "This project is generated by bit Contoso v-10.7.0-pre-02 using the following command:"); + + var record = TemplateDiff.ReadProject(host.WorkingDirectory); + + Assert.AreEqual("10.8.0", record.Version); + Assert.AreEqual("Contoso", record.Name); + CollectionAssert.AreEquivalent(new Dictionary { ["database"] = "PostgreSQL", ["aspire"] = "False", ["sample"] = "true" }, record.Options.ToDictionary()); + + var (arguments, _) = TemplateDiff.TemplateArguments(TemplateManifest.Embedded, record.Options); + CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL", "--aspire", "false", "--sample", "true" }, arguments); + + File.Delete(Path.Combine(host.WorkingDirectory, "bit.json")); + Assert.AreEqual("10.7.0-pre-02", TemplateDiff.ReadProject(host.WorkingDirectory).Version, "A project created before bit.json has its version in the README."); + } + [TestMethod] public void TheReadme_Should_GiveTheVersionNameOptionsAndPassword() { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md index ebd9e98dc58..5f742719da5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/18- Available Agent Skills.md @@ -178,12 +178,12 @@ Everything lives in the one skill file; it brings no scripts or tools of its own **Canonical file**: `.github/agents/upgrade.agent.md` -**What it does**: Brings a newer bit Boilerplate version into the project. `bit diff` creates the project with the version it came from and with the newer one into one git repository in a temporary folder, so the second commit is exactly what the template changed for a project with this name and these options. The skill carries each of those changes into the codebase, keeping what the team changed, adds the database migrations the changes need, and updates the version `README.md` and the MCP server configurations record. +**What it does**: Brings a newer bit Boilerplate version into the project. `bit diff` creates the project with the version it came from and with the newer one into one git repository in a temporary folder, so the second commit is exactly what the template changed for a project with this name and these options. The skill carries each of those changes into the codebase, keeping what the team changed, adds the database migrations the changes need, and updates the version `bit.json` and the MCP server configurations record. **When to use it**: When a newer bit Boilerplate is out and the project should get its fixes, features and dependency updates. **Key capabilities**: -- Reads the project's version and options from `README.md`, and checks them against the code before diffing +- Reads the project's version and options from `bit.json`, and checks them against the code before diffing - Treats the old version as the common ancestor: takes files the team never changed, merges files both changed, and leaves out what no longer has anything to attach to - Knows which differences come from generating a project (ports, GUIDs, dates, the seeded accounts' password, the app certificate) rather than from the upgrade - Adds a migration for every database context whose model changed, instead of editing existing ones diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md index 23c18acdfbf..b2347da43b9 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/19- Project Miscellaneous Files.md @@ -584,30 +584,18 @@ When you open the project in VS Code, you'll be prompted to install these extens **Contains**: - Welcome message -- Template creation command (showing exactly how this project was generated) +- The seeded accounts and their password, which is random for every project - Link to comprehensive documentation at [bitplatform.dev/templates](https://bitplatform.dev/templates/overview) -- Information about the template version used -**Example**: +### 8.2 `bit.json` -````markdown -This project gets generated by bit Boilerplate v-10.7.0-pre-02 using the following command -```bash -bit new Boilerplate - --database SqlServer - --filesStorage S3 - --module Admin - --captcha reCaptcha - --sample - --sentry - --appInsights - --signalR - --offlineDb - --ads -``` -```` +**Location**: [`/bit.json`](/bit.json) + +**Purpose**: Records how the project was created, for the [bit CLI](https://www.nuget.org/packages/Bit.Cli) and for you: the template (`bit-bp`), its version, the project's name, and the value of every template option. + +**Who uses it**: `bit diff` and the `upgrade` skill read the version and the options to create the project again with the old and the new template version. An upgrade updates the version in it, and new options arrive through the diff like any other change. -This is useful for remembering how the project was initially configured. +**Keep it**: the bit CLI may keep more of what it needs to know about the project here. Edit an option's value when you change the project in a way that matches another option, such as removing Aspire, so the next upgrade compares the right templates. --- diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md index aa387dcc17d..ecf2bee859e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md @@ -11,7 +11,7 @@ The work is yours to judge. Ask the user only what the code can't answer and wha ## 1. Know where the project stands -- `README.md` records the bit Boilerplate version the project comes from and the `bit new` command, with every option, that created it. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. When they disagree, the project may have been upgraded before without the README; the package versions of the bit platform libraries in `src/Directory.Packages.props` tell which one is right. +- `bit.json` records the bit Boilerplate version the project comes from, its name and the value of every option it was created with. A project created before `bit.json` existed has the version and the `bit new` command in its `README.md` instead, and gets `bit.json` through the upgrade. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. When they disagree, the project may have been upgraded before without updating them; the package versions of the bit platform libraries in `src/Directory.Packages.props` tell which one is right. - Check the recorded options against the code. The team may have turned something off after creation, switched the database provider, or removed a module, and the diff should be made with the options the project has today. - The target is the version the user names. Without one, it's the newest stable bit Boilerplate; take a prerelease only when the user asks. The release notes on GitHub for every version in between say why things changed, and the bit platform MCP server answers questions about any of them. - Start from a clean working tree on a new branch, so the whole upgrade can be reviewed as one change. If there are uncommitted changes, ask the user whether to commit or stash them first. @@ -43,7 +43,7 @@ Some changes need more than the text of the diff: ## 4. Update the version the project records -- In `README.md`, the version in the line that says which bit Boilerplate generated the project, so the next upgrade starts from here. Bring in the rest of the README's upstream changes like any other file. +- In `bit.json`, the version, so the next upgrade starts from here; keep the options the project really has. - The version in the bit platform MCP server URL in every MCP configuration listed above, and in any other agent configuration that pins it. - Search the project for the old version string and update every place that names the template or bit platform version, such as the package versions of the bit platform libraries. Leave other packages that happen to share the number alone. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index 17cc4a0b79a..de6e12624c6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -53,6 +53,7 @@ "displayName": "Backend database", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-database", "defaultValue": "Sqlite", "choices": [ { @@ -82,6 +83,7 @@ "displayName": "Files storage", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-filesStorage", "defaultValue": "Local", "choices": [ { @@ -107,6 +109,7 @@ "displayName": "API deployment configuration", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-api", "defaultValue": "Integrated", "choices": [ { @@ -123,6 +126,7 @@ "displayName": "CI-CD pipeline", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-pipeline", "defaultValue": "GitHub", "choices": [ { @@ -143,6 +147,7 @@ "displayName": "Module", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-module", "defaultValue": "None", "choices": [ { @@ -163,6 +168,7 @@ "displayName": "Multi-Tenant", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-multitenant", "defaultValue": "true", "description": "Adds multi-tenant support" }, @@ -170,6 +176,7 @@ "displayName": "Captcha", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-captcha", "defaultValue": "None", "choices": [ { @@ -186,6 +193,7 @@ "displayName": "Add aspire?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-aspire", "defaultValue": "true", "description": "Adds aspire" }, @@ -193,6 +201,7 @@ "displayName": "Add redis?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-redis", "defaultValue": "false", "description": "Adds redis" }, @@ -200,6 +209,7 @@ "displayName": "Add push notification?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-notification", "defaultValue": "true", "description": "Adds push notification." }, @@ -207,6 +217,7 @@ "displayName": "Sample", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-sample", "defaultValue": "false", "description": "Adds todo page sample." }, @@ -214,18 +225,21 @@ "displayName": "Add Sentry logging to project?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-sentry", "defaultValue": "false" }, "appInsights": { "displayName": "Add Azure application insights to project?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-appInsights", "defaultValue": "false" }, "signalR": { "displayName": "Add SignalR?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-signalR", "defaultValue": "false", "description": "Adds SignalR sample." }, @@ -233,6 +247,7 @@ "displayName": "Add Offline db?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-offlineDb", "defaultValue": "false", "description": "Adds support for EF Core DbContext on the client side. If your app doesn't need offline data storage, we recommend not enabling this feature." }, @@ -240,6 +255,7 @@ "displayName": "Add cloudflare CDN integration?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-cloudflare", "defaultValue": "true", "description": "Adds cloudflare CDN integration." }, @@ -247,6 +263,7 @@ "displayName": "Add Ads?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-ads", "defaultValue": "false", "description": "Adds GoogleAds integration." }, @@ -254,6 +271,7 @@ "displayName": "Use bit Brouter?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-brouter", "defaultValue": "true", "description": "Replaces the built-in Blazor Router with bit Brouter, bringing advanced routing features - nested routes, route guards, data loaders, keep-alive pages and view transitions - inspired by the routers of Vue.js, React and Angular." }, @@ -261,6 +279,7 @@ "displayName": "Theme", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-theme", "defaultValue": "Fluent2", "choices": [ { @@ -683,6 +702,7 @@ "displayName": "Include advanced automated tests?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-advancedTests", "defaultValue": "false", "description": "Includes advanced automated tests (e.g. the Blazor WebAssembly offline sync test) that add extra infrastructure not needed by a typical project." } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln index b9c17a0da5b..0d503aed874 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln +++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln @@ -25,6 +25,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".SolutionItems", ".Solution vs-spell.dic = vs-spell.dic .vscode\mcp.json=.vscode\mcp.json README.md = README.md + bit.json = bit.json EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Client", "Client", "{248D8229-BABD-4F0A-A9C6-0417B464507B}" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx index 8d0e43dff38..49fd60136c7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx @@ -14,6 +14,7 @@ + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/README.md b/src/Templates/Boilerplate/Bit.Boilerplate/README.md index 5e807fae222..54ff250eff1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/README.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/README.md @@ -2,92 +2,6 @@ Thank you for creating a new project with bit platform! We appreciate your trust in our platform and are excited to see what you'll build. -This project is generated by bit Boilerplate v-10.7.0-pre-02 using the following command: - -```bash - bit new Boilerplate - - --database SqlServer - - --database PostgreSQL - - --database MySql - - --database Other - - - --filesStorage S3 - - --filesStorage AzureBlobStorage - - --filesStorage Other - - - --api Standalone - - - --pipeline Azure - - --pipeline None - - - --module Admin - - --module Sales - - - --captcha reCaptcha - - - --aspire false - - - --notification false - - - --sample - - - --sentry - - - --appInsights - - - --signalR - - - --offlineDb - - - --cloudflare false - - - --ads - - - --multitenant false - - - --redis - - - --brouter false - - - --theme Fluent - - --theme Cupertino - - --theme Material - - - --advancedTests - -``` - -> Recorded for reference. The block is one argument per line, so it is not meant to be pasted into a shell as-is. `bit new` comes from the [bit CLI](https://www.nuget.org/packages/Bit.Cli): `dotnet tool install --global Bit.Cli --prerelease`. - ## ⚠️ Seeded accounts - remove them before you deploy So that the app is usable and testable the moment it is generated, the database seed diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/bit.json b/src/Templates/Boilerplate/Bit.Boilerplate/bit.json new file mode 100644 index 00000000000..616ef7c5037 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/bit.json @@ -0,0 +1,27 @@ +{ + "template": "bit-bp", + "version": "10.7.0-pre-02", + "name": "Boilerplate", + "options": { + "database": "bit-option-database", + "filesStorage": "bit-option-filesStorage", + "api": "bit-option-api", + "pipeline": "bit-option-pipeline", + "module": "bit-option-module", + "multitenant": "bit-option-multitenant", + "captcha": "bit-option-captcha", + "aspire": "bit-option-aspire", + "redis": "bit-option-redis", + "notification": "bit-option-notification", + "sample": "bit-option-sample", + "sentry": "bit-option-sentry", + "appInsights": "bit-option-appInsights", + "signalR": "bit-option-signalR", + "offlineDb": "bit-option-offlineDb", + "cloudflare": "bit-option-cloudflare", + "ads": "bit-option-ads", + "brouter": "bit-option-brouter", + "theme": "bit-option-theme", + "advancedTests": "bit-option-advancedTests" + } +} From fb4094764207fa372b7b7167d85156ba9a0668b6 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:45:07 +0200 Subject: [PATCH 08/12] fix(cli): Esc skips the GitHub sign-in, extensions are checked and retried, no README fallback #13974 --- src/Cli/Bit.Cli/Projects/NewWorkflow.cs | 9 +- src/Cli/Bit.Cli/Projects/ProjectSteps.cs | 109 +++++++++++++----- src/Cli/Bit.Cli/Projects/TemplateDiff.cs | 64 +--------- src/Cli/README.md | 2 +- src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs | 43 +------ .../Tests/Bit.Cli.Tests/ProjectStepTests.cs | 95 +++++++++++---- .../.github/agents/upgrade.agent.md | 2 +- 7 files changed, 175 insertions(+), 149 deletions(-) diff --git a/src/Cli/Bit.Cli/Projects/NewWorkflow.cs b/src/Cli/Bit.Cli/Projects/NewWorkflow.cs index e10f2cfa5ef..a210734815e 100644 --- a/src/Cli/Bit.Cli/Projects/NewWorkflow.cs +++ b/src/Cli/Bit.Cli/Projects/NewWorkflow.cs @@ -8,7 +8,7 @@ namespace Bit.Cli.Projects; -public sealed class NewWorkflow(CliServices cli) +public sealed partial class NewWorkflow(CliServices cli) { private const string CreateProjectPage = "https://bitplatform.dev/templates/create-project"; @@ -417,7 +417,7 @@ await steps.RunAsync("create", $"Creating {context.Name}", async (progress, ct) var legacySolution = Path.ChangeExtension(context.Solution, ".sln"); var readme = Path.Combine(context.Directory, "README.md"); - context.SeedPassword = File.Exists(readme) ? TemplateDiff.ReadReadme(File.ReadAllText(readme)).SeedPassword : null; + context.SeedPassword = File.Exists(readme) ? ReadSeedPassword(File.ReadAllText(readme)) : null; if (result.Succeeded && File.Exists(context.Solution) && File.Exists(legacySolution)) { @@ -568,4 +568,9 @@ private int UsageError(string message) cli.Console.Fail(message); return CliApp.ExitUsage; } + + public static string? ReadSeedPassword(string readme) => SeedPasswordRegex().Match(readme) is { Success: true } match ? match.Groups["password"].Value : null; + + [System.Text.RegularExpressions.GeneratedRegex(@"password is \*\*`(?[^`]+)`\*\*", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex SeedPasswordRegex(); } diff --git a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs index 2eda06f3a7f..2cf8984434f 100644 --- a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs +++ b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs @@ -7,6 +7,7 @@ using Bit.Cli.Templates; using Bit.Cli.Tools; using Bit.Cli.Trust; +using Spectre.Console; namespace Bit.Cli.Projects; @@ -724,36 +725,47 @@ public async Task VsCodeExtensionsAsync(Action progress, Can if (IdeLocator.FindVsCode(cli.Environment, Runner) is not { } code) return StepResult.Skipped("Skipped VS Code extensions", "VS Code isn't installed"); - var listed = await Runner.RunAsync(new ProcessSpec { FileName = code.Executable, Arguments = ["--list-extensions"], Timeout = TimeSpan.FromMinutes(2) }, cancellationToken); - var installed = listed.Succeeded ? listed.OutputLines.Select(l => l.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase) : []; - var missing = recommended.Where(id => installed.Contains(id) is false).ToList(); + var installed = await ListExtensionsAsync(code, cancellationToken); + var wanted = recommended.Where(id => installed?.Contains(id) is not true).ToList(); - if (listed.Succeeded && missing.Count == 0) + if (installed is not null && wanted.Count == 0) return StepResult.Succeeded("Extensions already installed", $"{recommended.Count} recommended for VS Code"); - string[] arguments = [.. missing.SelectMany(id => new[] { "--install-extension", id })]; - var install = new ProcessSpec { FileName = code.Executable, Arguments = arguments, OnOutputLine = progress, Timeout = TimeSpan.FromMinutes(15) }; - var result = await Runner.RunAsync(install, cancellationToken); - var builtIn = BuiltInExtensionRegex().Matches(result.Output).Select(m => m.Groups["id"].Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = wanted; + var builtIn = new HashSet(StringComparer.OrdinalIgnoreCase); + ProcessResult? last = null; - if (result.Succeeded is false && builtIn.Count > 0) + for (var attempt = 0; attempt < 2 && missing.Count > 0; attempt++) { - missing = [.. missing.Where(id => builtIn.Contains(id) is false)]; - - if (missing.Count == 0) - return StepResult.Succeeded("Extensions already installed", $"{recommended.Count} recommended for VS Code"); + last = await Runner.RunAsync(new ProcessSpec { FileName = code.Executable, Arguments = InstallArguments(missing), OnOutputLine = progress, Timeout = TimeSpan.FromMinutes(15) }, cancellationToken); + builtIn.UnionWith(BuiltInExtensionRegex().Matches(last.Output).Select(m => m.Groups["id"].Value)); - arguments = [.. missing.SelectMany(id => new[] { "--install-extension", id })]; - install = install with { Arguments = arguments }; - result = await Runner.RunAsync(install, cancellationToken); + var now = await ListExtensionsAsync(code, cancellationToken) ?? []; + missing = [.. missing.Where(id => now.Contains(id) is false && builtIn.Contains(id) is false)]; } - var names = string.Join(", ", missing.Take(3).Select(id => id[(id.IndexOf('.') + 1)..])) + (missing.Count > 3 ? $" and {missing.Count - 3} more" : ""); + var added = wanted.Where(id => builtIn.Contains(id) is false && missing.Contains(id) is false).ToList(); - return StepResult.FromProcess(result, $"Installed {missing.Count} VS Code extension{(missing.Count == 1 ? "" : "s")}", "Couldn't install the VS Code extensions", - $"{ProcessSpec.Quote(code.Executable)} {string.Join(' ', arguments)}", names); + if (missing.Count > 0) + return StepResult.Warning($"Couldn't install {missing.Count} VS Code extension{(missing.Count == 1 ? "" : "s")}", ExtensionNames(missing), + $"{ProcessSpec.Quote(code.Executable)} {string.Join(' ', InstallArguments(missing))}", last is null ? null : DiagnosticCodes.FirstErrorLine(last.Output)); + + return added.Count == 0 + ? StepResult.Succeeded("Extensions already installed", $"{recommended.Count} recommended for VS Code") + : StepResult.Succeeded($"Installed {added.Count} VS Code extension{(added.Count == 1 ? "" : "s")}", ExtensionNames(added)); } + private async Task?> ListExtensionsAsync(Ide code, CancellationToken cancellationToken) + { + var listed = await Runner.RunAsync(new ProcessSpec { FileName = code.Executable, Arguments = ["--list-extensions"], Timeout = TimeSpan.FromMinutes(2) }, cancellationToken); + return listed.Succeeded ? listed.OutputLines.Select(l => l.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase) : null; + } + + private static string[] InstallArguments(IEnumerable extensions) => [.. extensions.SelectMany(id => new[] { "--install-extension", id })]; + + private static string ExtensionNames(IReadOnlyList extensions) + => string.Join(", ", extensions.Take(3).Select(id => id[(id.IndexOf('.') + 1)..])) + (extensions.Count > 3 ? $" and {extensions.Count - 3} more" : ""); + private static readonly string[] visualStudioLicensedExtensions = ["ms-dotnettools.csdevkit", "ms-dotnettools.dotnet-maui"]; public static IReadOnlyList ExtensionsToInstall(IReadOnlyList recommended) @@ -860,18 +872,60 @@ public async Task GitHubSignInAsync(Action progress, Cancell { await Gh(["auth", "setup-git", "--hostname", "github.com", "--force"], cancellationToken); - var login = await Runner.RunAsync(new ProcessSpec + using var signIn = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var codeShown = 0; + + var login = Runner.RunAsync(new ProcessSpec { FileName = "gh", Arguments = ["auth", "login", "--web", "--git-protocol", "https", "--hostname", "github.com"], WorkingDirectory = project.Directory, - Interactive = true, - Timeout = TimeSpan.FromMinutes(15) - }, cancellationToken); + Timeout = TimeSpan.FromMinutes(15), + OnOutputLine = line => + { + if (OneTimeCodeRegex().Match(line) is not { Success: true } code || Interlocked.Exchange(ref codeShown, 1) is 1) + return; + + cli.Console.Out.MarkupLine($" Enter [bold]{code.Value}[/] [grey](copied to the clipboard)[/] at {GitHubDevicePage}, which opens in your browser."); + cli.Console.Out.MarkupLine(" [grey]Press Esc to skip signing in; bit goes on without the GitHub repo.[/]"); + ProjectRun.OpenInBrowser(cli, GitHubDevicePage); + } + }, signIn.Token); + + var skip = SkipRequested(signIn.Token); + var skipped = await Task.WhenAny(login, skip) == skip && skip.IsCompletedSuccessfully; + + await signIn.CancelAsync(); - return login.Succeeded - ? StepResult.Succeeded("Signed in to GitHub") - : StepResult.Warning("Not signed in to GitHub", "the sign-in didn't finish", "gh auth login --web"); + try + { + var result = await login; + + if (skipped is false) + return result.Succeeded + ? StepResult.Succeeded("Signed in to GitHub") + : StepResult.Warning("Not signed in to GitHub", "the sign-in didn't finish", "gh auth login --web"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested is false) + { + } + + return StepResult.Warning("Skipped signing in to GitHub", "you pressed Esc", "gh auth login --web"); + } + + private const string GitHubDevicePage = "https://github.com/login/device"; + + public Func SkipRequested { get; init; } = WaitForEscapeAsync; + + private static async Task WaitForEscapeAsync(CancellationToken cancellationToken) + { + while (true) + { + if (Console.IsInputRedirected is false && Console.KeyAvailable && Console.ReadKey(intercept: true).Key is ConsoleKey.Escape) + return; + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + } } public async Task GitHubRepositoryAsync(Action progress, CancellationToken cancellationToken) @@ -962,6 +1016,9 @@ private static bool NeedsElevation(string output) [GeneratedRegex(@"[^A-Za-z0-9._-]+")] private static partial Regex GitEmailRegex(); + [GeneratedRegex(@"\b[A-Z0-9]{4}-[A-Z0-9]{4}\b")] + private static partial Regex OneTimeCodeRegex(); + [GeneratedRegex(@"""dashboardUrl""\s*:\s*""(?[^""]+)""")] private static partial Regex DashboardUrlRegex(); diff --git a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs index aefcd8e747d..b499d508ad2 100644 --- a/src/Cli/Bit.Cli/Projects/TemplateDiff.cs +++ b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Bit.Cli.Infrastructure; using Bit.Cli.Templates; using Spectre.Console; @@ -23,9 +22,9 @@ public sealed record DiffRequest public IReadOnlyDictionary TemplateValues { get; init; } = new Dictionary(); } -public sealed record ProjectRecord(string? Version, string? Name, IReadOnlyDictionary Options, string? SeedPassword); +public sealed record ProjectRecord(string? Version, string? Name, IReadOnlyDictionary Options); -public sealed partial class TemplateDiff(CliServices cli) +public sealed class TemplateDiff(CliServices cli) { public async Task RunAsync(DiffRequest request, CancellationToken cancellationToken) { @@ -186,11 +185,9 @@ public static ProjectRecord ReadProject(string directory) { var bitJson = Path.Combine(directory, "bit.json"); - if (File.Exists(bitJson) && ReadBitJson(File.ReadAllText(bitJson)) is { } record) - return record; - - var readme = Path.Combine(directory, "README.md"); - return File.Exists(readme) ? ReadReadme(File.ReadAllText(readme)) : new ProjectRecord(null, null, new Dictionary(), null); + return File.Exists(bitJson) && ReadBitJson(File.ReadAllText(bitJson)) is { } record + ? record + : new ProjectRecord(null, null, new Dictionary()); } public static ProjectRecord? ReadBitJson(string json) @@ -218,7 +215,7 @@ public static ProjectRecord ReadProject(string directory) } } - return new ProjectRecord(Text("version"), Text("name"), options, null); + return new ProjectRecord(Text("version"), Text("name"), options); } catch (JsonException) { @@ -226,46 +223,6 @@ public static ProjectRecord ReadProject(string directory) } } - public static ProjectRecord ReadReadme(string readme) - { - var version = VersionRegex().Match(readme) is { Success: true } versionMatch ? versionMatch.Groups["version"].Value : null; - var password = PasswordRegex().Match(readme) is { Success: true } passwordMatch ? passwordMatch.Groups["password"].Value : null; - var options = new Dictionary(StringComparer.OrdinalIgnoreCase); - string? name = null; - - if (CommandRegex().Match(readme) is { Success: true } commandMatch) - { - var tokens = commandMatch.Groups["command"].Value.Split((char[])[' ', '\t', '\r', '\n', '\\', '`'], StringSplitOptions.RemoveEmptyEntries); - var start = Array.FindIndex(tokens, token => token is "new"); - var first = start > 0 && tokens[start - 1] is "dotnet" ? start + 2 : start + 1; - - for (var i = first; i < tokens.Length; i++) - { - var token = tokens[i]; - - if (token.StartsWith('-') is false) - { - name ??= token; - continue; - } - - var option = token.TrimStart('-'); - var value = i + 1 < tokens.Length && tokens[i + 1].StartsWith('-') is false ? tokens[++i] : "true"; - - if (option is "name" or "n") - { - name = value; - } - else if (option is not ("output" or "o" or "yes" or "y")) - { - options[option] = value; - } - } - } - - return new ProjectRecord(version, name, options, password); - } - private static IEnumerable Remove(FileSystemInfo entry) { if (entry is DirectoryInfo) @@ -293,13 +250,4 @@ private int UsageError(string message) cli.Console.Fail(message); return CliApp.ExitUsage; } - - [GeneratedRegex(@"generated by bit \S+ v-?(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)", RegexOptions.IgnoreCase)] - private static partial Regex VersionRegex(); - - [GeneratedRegex(@"```[a-z]*\s*(?(?:bit|dotnet)\s+new\s[^`]*)```", RegexOptions.IgnoreCase)] - private static partial Regex CommandRegex(); - - [GeneratedRegex(@"password is \*\*`(?[^`]+)`\*\*", RegexOptions.IgnoreCase)] - private static partial Regex PasswordRegex(); } diff --git a/src/Cli/README.md b/src/Cli/README.md index a216fd56c88..9037fa3e861 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -113,7 +113,7 @@ bit diff --template-package ../bitplatform/src/Templates/Boilerplate/Bit.Boilerp ``` - It creates the project with each version, the old one first, into one git repository in the temp folder, so the second commit is exactly the upgrade, and writes it as `bit-diff.patch` in that repository too. Your project isn't touched. -- In a project, it takes the name, the version and the options from the project's `bit.json`, where the template records them; a project created before `bit.json` has them in its `README.md`. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. +- In a project, it takes the name, the version and the options from the project's `bit.json`, where the template records them. Pass `from`, `to`, `--name` or any option of `bit new` to change them. `to` defaults to this bit's own version. - An option a version doesn't have is left out for that version, and said so. - What the template picks at random on every run, like ports, GUIDs, dates and the seeded accounts' password, differs between the two, and so does what `bit new` adds after creating a project. - The repository goes to `/bit-diff/--`, or `-o `. Delete it with `bit remove --yes` when you're done. diff --git a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs index e38962559e3..ea7e10594c1 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs @@ -18,7 +18,6 @@ public void BitJson_Should_GiveTheVersionNameAndOptions() "options": { "database": "PostgreSQL", "aspire": "False", "sample": true, "url": 5 } } """); - File.WriteAllText(Path.Combine(host.WorkingDirectory, "README.md"), "This project is generated by bit Contoso v-10.7.0-pre-02 using the following command:"); var record = TemplateDiff.ReadProject(host.WorkingDirectory); @@ -30,48 +29,14 @@ public void BitJson_Should_GiveTheVersionNameAndOptions() CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL", "--aspire", "false", "--sample", "true" }, arguments); File.Delete(Path.Combine(host.WorkingDirectory, "bit.json")); - Assert.AreEqual("10.7.0-pre-02", TemplateDiff.ReadProject(host.WorkingDirectory).Version, "A project created before bit.json has its version in the README."); + Assert.IsNull(TemplateDiff.ReadProject(host.WorkingDirectory).Version); } [TestMethod] - public void TheReadme_Should_GiveTheVersionNameOptionsAndPassword() + public void TheSummary_Should_ShowThePasswordTheReadmeNames() { - var record = TemplateDiff.ReadReadme(""" - ## Welcome! - - This project is generated by bit Contoso v-10.7.0-pre-02 using the following command: - - ```bash - bit new Contoso - --database PostgreSQL - --module Admin - --aspire false - --sample - ``` - - pre-confirmed accounts whose password is **`40817263`**: - """); - - Assert.AreEqual("10.7.0-pre-02", record.Version); - Assert.AreEqual("Contoso", record.Name); - Assert.AreEqual("40817263", record.SeedPassword); - CollectionAssert.AreEquivalent(new Dictionary { ["database"] = "PostgreSQL", ["module"] = "Admin", ["aspire"] = "false", ["sample"] = "true" }, record.Options.ToDictionary()); - } - - [TestMethod] - public void AnOlderReadme_Should_GiveTheDotnetNewCommand() - { - var record = TemplateDiff.ReadReadme(""" - This project gets generated by bit Contoso v-9.11.2 using the following command - ```bash - dotnet new bit-bp --name Contoso --database SqlServer --signalR - ``` - """); - - Assert.AreEqual("9.11.2", record.Version); - Assert.AreEqual("Contoso", record.Name); - Assert.IsNull(record.SeedPassword); - CollectionAssert.AreEquivalent(new Dictionary { ["database"] = "SqlServer", ["signalR"] = "true" }, record.Options.ToDictionary()); + Assert.AreEqual("40817263", NewWorkflow.ReadSeedPassword("pre-confirmed accounts whose password is **`40817263`**:")); + Assert.IsNull(NewWorkflow.ReadSeedPassword("## Welcome!")); } [TestMethod] diff --git a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs index 19b59362dbb..4bdf0f399ea 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs @@ -646,48 +646,55 @@ public void CSharpDevKitAndMaui_Should_StayRecommendedButNotBeInstalled() public async Task ExtensionsVsCodeBuildsIn_Should_CountAsInstalled() { using var host = new TestHost(HostOs.Linux); - var code = typeof(ProjectStepTests).Assembly.Location; - var name = Path.GetFileNameWithoutExtension(code); - host.Runner.Executables["code"] = code; - var project = CreateFakeProject(host, "Contoso"); + var (project, vsCode) = FakeVsCode(host, "ms-dotnettools.csdevkit"); WriteRecommendations(project, "Anthropic.claude-code", "vscode.git"); - host.Runner.On(name, "--list-extensions", 0, "ms-dotnettools.csdevkit\n"); - host.Runner.On(name, "--install-extension Anthropic.claude-code", 0); - host.Runner.On(name, "--install-extension Anthropic.claude-code --install-extension vscode.git", 1, - "Error while installing extension vscode.git: Extension 'vscode.git' is a built-in extension with version '1.112.0' and cannot be downgraded to version '1.0.0'.\nFailed Installing Extensions: vscode.git"); + host.Runner.On(vsCode.Name, "--install-extension", spec => + { + vsCode.Installed.Add("anthropic.claude-code"); + return new ProcessResult { ExitCode = 1, Output = "Error while installing extension vscode.git: Extension 'vscode.git' is a built-in extension with version '1.112.0' and cannot be downgraded to version '1.0.0'.\nFailed Installing Extensions: vscode.git" }; + }); var result = await new ProjectSteps(host.Services, project).VsCodeExtensionsAsync(_ => { }, CancellationToken.None); Assert.AreEqual(StepStatus.Succeeded, result.Status, result.Detail + result.FollowUp); Assert.AreEqual("Installed 1 VS Code extension", result.Title); - CollectionAssert.AreEqual(new[] { "--install-extension", "Anthropic.claude-code" }, host.Runner.Calls.Last().Arguments.ToArray()); + Assert.AreEqual(1, host.Runner.Calls.Count(c => c.Arguments.FirstOrDefault() is "--install-extension")); } [TestMethod] - public async Task AFailedExtensionInstall_Should_PointAtVsCodesOwnPath() + public async Task AFailedExtensionInstall_Should_BeRetriedOnceAndNamed() { using var host = new TestHost(HostOs.Linux); - var code = typeof(ProjectStepTests).Assembly.Location; - host.Runner.Executables["code"] = code; - var project = CreateFakeProject(host, "Contoso"); - WriteRecommendations(project, "Anthropic.claude-code"); - host.Runner.On(Path.GetFileNameWithoutExtension(code), "--install-extension", 1, "error: the marketplace can't be reached"); + var (project, vsCode) = FakeVsCode(host); + WriteRecommendations(project, "Anthropic.claude-code", "EditorConfig.EditorConfig"); + var attempts = 0; + host.Runner.On(vsCode.Name, "--install-extension", spec => + { + vsCode.Installed.Add("editorconfig.editorconfig"); + return new ProcessResult { ExitCode = 1, Output = ++attempts == 1 ? "error: the marketplace can't be reached" : "Failed Installing Extensions: anthropic.claude-code" }; + }); var result = await new ProjectSteps(host.Services, project).VsCodeExtensionsAsync(_ => { }, CancellationToken.None); - Assert.AreEqual(StepStatus.Failed, result.Status); - Assert.AreEqual($"{ProcessSpec.Quote(code)} --install-extension Anthropic.claude-code", result.FollowUp); + Assert.AreEqual(StepStatus.Warning, result.Status, "An extension that won't install is a note, not a failed run."); + Assert.AreEqual("Couldn't install 1 VS Code extension", result.Title); + Assert.AreEqual("claude-code", result.Detail); + Assert.AreEqual($"{ProcessSpec.Quote(vsCode.Path)} --install-extension Anthropic.claude-code", result.FollowUp); + Assert.AreEqual(2, attempts); + CollectionAssert.AreEqual(new[] { "--install-extension", "Anthropic.claude-code" }, host.Runner.Calls.Where(c => c.Arguments.FirstOrDefault() is "--install-extension").Last().Arguments.ToArray()); } [TestMethod] public async Task VsCode_Should_GetOnlyTheExtensionsItLacks() { using var host = new TestHost(HostOs.Linux); - var code = typeof(ProjectStepTests).Assembly.Location; - host.Runner.Executables["code"] = code; - var project = CreateFakeProject(host, "Contoso"); + var (project, vsCode) = FakeVsCode(host, "ms-dotnettools.csharp", "ms-dotnettools.csdevkit"); WriteRecommendations(project, "ms-dotnettools.csdevkit", "Anthropic.claude-code", "ms-dotnettools.csharp"); - host.Runner.On(Path.GetFileNameWithoutExtension(code), "--list-extensions", 0, "ms-dotnettools.csharp\nms-dotnettools.csdevkit\n"); + host.Runner.On(vsCode.Name, "--install-extension", spec => + { + vsCode.Installed.UnionWith(spec.Arguments.Where(a => a.StartsWith('-') is false)); + return new ProcessResult { ExitCode = 0 }; + }); var result = await new ProjectSteps(host.Services, project).VsCodeExtensionsAsync(_ => { }, CancellationToken.None); @@ -696,13 +703,23 @@ public async Task VsCode_Should_GetOnlyTheExtensionsItLacks() var install = host.Runner.Calls.Single(c => c.Arguments.FirstOrDefault() is "--install-extension"); CollectionAssert.AreEqual(new[] { "--install-extension", "Anthropic.claude-code" }, install.Arguments.ToArray()); - host.Runner.On(Path.GetFileNameWithoutExtension(code), "--list-extensions", 0, "anthropic.claude-code\nms-dotnettools.csharp\nms-dotnettools.csdevkit\n"); var done = await new ProjectSteps(host.Services, project).VsCodeExtensionsAsync(_ => { }, CancellationToken.None); Assert.AreEqual("Extensions already installed", done.Title); Assert.AreEqual(1, host.Runner.Calls.Count(c => c.Arguments.FirstOrDefault() is "--install-extension")); } + private sealed record FakeVsCodeInstall(string Path, string Name, HashSet Installed); + + private static (ProjectContext Project, FakeVsCodeInstall VsCode) FakeVsCode(TestHost host, params string[] installed) + { + var code = typeof(ProjectStepTests).Assembly.Location; + var vsCode = new FakeVsCodeInstall(code, Path.GetFileNameWithoutExtension(code), new HashSet(installed, StringComparer.OrdinalIgnoreCase)); + host.Runner.Executables["code"] = code; + host.Runner.On(vsCode.Name, "--list-extensions", _ => new ProcessResult { ExitCode = 0, Output = string.Join('\n', vsCode.Installed) }); + return (CreateFakeProject(host, "Contoso"), vsCode); + } + [TestMethod] public async Task Playwright_Should_InstallChromiumWithTheTestsOwnDriver() { @@ -845,6 +862,40 @@ public async Task GitHubSignIn_Should_SetUpGitFirstSoGhAsksNothingButTheCode() host.Runner.Calls.Where(c => c.FileName is "gh").Select(c => string.Join(' ', c.Arguments)).ToArray()); } + [TestMethod] + public async Task GitHubSignIn_Should_ShowTheCodeAndLetEscSkipIt() + { + using var host = new TestHost(); + var project = CreateFakeProject(host, "Contoso"); + host.Runner.Executables["gh"] = "/usr/bin/gh"; + var stopped = false; + host.Runner.OnAsync("gh", "auth login", async (spec, ct) => + { + spec.OnOutputLine?.Invoke("! One-time code (AB12-CD34) copied to clipboard"); + spec.OnOutputLine?.Invoke("Open this URL to continue in your web browser: https://github.com/login/device"); + + try + { + await Task.Delay(Timeout.Infinite, ct); + } + finally + { + stopped = true; + } + + return new ProcessResult { ExitCode = 0 }; + }); + + var result = await new ProjectSteps(host.Services, project) { SkipRequested = _ => Task.CompletedTask }.GitHubSignInAsync(_ => { }, CancellationToken.None); + + Assert.AreEqual(StepStatus.Warning, result.Status); + Assert.AreEqual("Skipped signing in to GitHub", result.Title); + Assert.IsTrue(stopped, "Skipping stops gh's sign-in."); + StringAssert.Contains(host.FlatOutput, "Enter AB12-CD34"); + StringAssert.Contains(host.FlatOutput, "Press Esc to skip"); + CollectionAssert.AreEqual(new[] { "https://github.com/login/device" }, host.Runner.Detached.SelectMany(d => d.Arguments).Where(a => a.StartsWith("https", StringComparison.Ordinal)).ToArray()); + } + [TestMethod] public async Task GitHubSignIn_Should_NeedATerminalButNotPrompts() { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md index ecf2bee859e..d3f3c633749 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md @@ -11,7 +11,7 @@ The work is yours to judge. Ask the user only what the code can't answer and wha ## 1. Know where the project stands -- `bit.json` records the bit Boilerplate version the project comes from, its name and the value of every option it was created with. A project created before `bit.json` existed has the version and the `bit new` command in its `README.md` instead, and gets `bit.json` through the upgrade. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. When they disagree, the project may have been upgraded before without updating them; the package versions of the bit platform libraries in `src/Directory.Packages.props` tell which one is right. +- `bit.json` records the bit Boilerplate version the project comes from, its name and the value of every option it was created with. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. When they disagree, the project may have been upgraded before without updating them; the package versions of the bit platform libraries in `src/Directory.Packages.props` tell which one is right. - Check the recorded options against the code. The team may have turned something off after creation, switched the database provider, or removed a module, and the diff should be made with the options the project has today. - The target is the version the user names. Without one, it's the newest stable bit Boilerplate; take a prerelease only when the user asks. The release notes on GitHub for every version in between say why things changed, and the bit platform MCP server answers questions about any of them. - Start from a clean working tree on a new branch, so the whole upgrade can be reviewed as one change. If there are uncommitted changes, ask the user whether to commit or stash them first. From 2c8341812e5bacefc4804f62228a604b99981277 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:45:41 +0200 Subject: [PATCH 09/12] docs(cli): sign-in skip and extension checks #13974 --- src/Cli/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli/README.md b/src/Cli/README.md index 9037fa3e861..723fba266d5 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -27,9 +27,9 @@ It needs a .NET 10 SDK or later. `dnx` asks once before it downloads the package 6. Checks that `dotnet` picks an SDK the project's `global.json` accepts; when it doesn't, the steps that need one are skipped with a single note instead of failing one by one. Then installs the build tools the chosen platforms need (.NET workloads), restores NuGet packages, and builds, which also generates the CSS and JS. With only the web app it builds `Web.slnf`; once any native app is picked it builds the whole `.slnx`, every app the MAUI project targets on this OS included, so it also installs the MAUI build tools and the Android SDK. When the build fails with Razor errors, it restarts the C# compiler server and builds once more: a compiler server that has gone bad reports Razor errors in files that are fine, and keeps doing so for every build until it restarts. Then it installs the Chromium that Playwright runs the UI tests with. 7. Runs `dotnet format`, and commits that on its own. 8. Adds the `Initial` EF Core migration, and commits it on its own. The app applies migrations when it starts. -9. With `--github-repo`, signs you in to GitHub in your browser when needed, creates a private repository named after the project, pushes `develop` and `main` to it, and makes `develop` its default branch. +9. With `--github-repo`, signs you in to GitHub when needed: it shows the one-time code, opens GitHub's device page in your browser, and Esc skips the sign-in and the repository if it gets stuck. Then it creates a private repository named after the project, pushes `develop` and `main` to it, and makes `develop` its default branch. 10. Marks the folder as trusted for VS Code, Claude Code, Copilot CLI, Codex and Gemini CLI, so the project's tasks and MCP servers work without prompts. That works even when VS Code was just installed and has never run. -11. Installs the VS Code extensions the project recommends, like the C# extension and Claude Code. C# Dev Kit and the .NET MAUI extension stay recommended, so VS Code offers them, but bit doesn't install them: they come with Visual Studio's license, which is free for teams of up to five. Copilot is built into VS Code, so it isn't one of them. +11. Installs the VS Code extensions the project recommends, like the C# extension and Claude Code. C# Dev Kit and the .NET MAUI extension stay recommended, so VS Code offers them, but bit doesn't install them: they come with Visual Studio's license, which is free for teams of up to five. Copilot is built into VS Code, so it isn't one of them. bit checks in VS Code that each one is really there afterwards, tries the missing ones once more, and names any it couldn't install. 12. Starts the project: with Aspire, `aspire start`; without it, `dotnet run` of Server.Web (and of Server.Api when the API is standalone). It waits until the servers are healthy, comes last so everything else is ready however long it takes, and while it waits, shows which resources are still starting or unhealthy, and stops waiting as soon as one fails to start. It asks `aspire doctor` first whether Aspire can use Docker or Podman. Without one, a project whose servers wait for a container (SQL Server, PostgreSQL, MySQL or Redis) isn't started, with why and how to fix it; any other project starts without its containers, so only Keycloak, Mailpit and the like are missing. 13. Opens the project in VS Code, or the IDE you pick. All that's left is signing in to Claude or Copilot. 14. Opens the Aspire dashboard and then the app in your browser, and keeps the project running while the terminal you ran bit in is open. Close it, or press Ctrl+C, to stop the project. Without a terminal, such as under a coding agent or in a dev container, it only starts the project once with Aspire, so the container images are pulled, and stops it; CI starts nothing. From 8d4bfd90faf63ac6f7029be6c1a8c0e0475a7bdf Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:54:28 +0200 Subject: [PATCH 10/12] fix(cli): review fixes for Podman after WSL, health reports and the upgrade skill #13974 --- src/Cli/Bit.Cli/Tools/ToolCatalog.cs | 18 +++++++++++++++--- .../Tests/Bit.Cli.Tests/ProjectStepTests.cs | 2 +- src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs | 17 ++++++++++++----- .../.github/agents/upgrade.agent.md | 4 ++-- .../Pages/Management/OperationsPage.razor.cs | 6 +++++- .../Features/HealthChecks/HealthCheckTests.cs | 1 + 6 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/Cli/Bit.Cli/Tools/ToolCatalog.cs b/src/Cli/Bit.Cli/Tools/ToolCatalog.cs index 8f4b8d66fc4..933e7e229c9 100644 --- a/src/Cli/Bit.Cli/Tools/ToolCatalog.cs +++ b/src/Cli/Bit.Cli/Tools/ToolCatalog.cs @@ -49,6 +49,11 @@ public static async Task> CheckAsync(ToolContext contex var dockerMissing = checks.Any(c => c.Tool is DockerTool && c.Status.State is ToolState.Missing); checks.RemoveAll(c => c.Tool is WslTool && dockerMissing is false); + if (checks.Any(c => c.Tool is WslTool && c.Status.IsSatisfied is false) && checks.FindIndex(c => c.Tool is DockerTool && c.Action is not null) is var docker and >= 0) + { + checks[docker] = checks[docker] with { Action = DockerTool.AfterWslRestart(checks[docker].Action!) }; + } + var brewNeeded = checks.Any(c => c.Status.IsSatisfied is false && c.Tool is not HomebrewTool && c.Action is null && context.Environment.IsMacOS && context.PackageManagers.Brew is null && c.Tool is NodeTool or GitTool or DockerTool or VsCodeTool); checks.RemoveAll(c => c.Tool is HomebrewTool && (c.Status.IsSatisfied || brewNeeded is false)); @@ -645,15 +650,22 @@ HostOs.MacOS when Directory.Exists("/Applications/Docker.app") private static IReadOnlyList PodmanMachine(string podman, bool init) { - var startAtSignIn = $"powershell.exe -NoProfile -WindowStyle Hidden -Command \"& '{podman.Replace("'", "''", StringComparison.Ordinal)}' machine start\""; + var quoted = podman.Replace("'", "''", StringComparison.Ordinal); + var atSignIn = $"powershell.exe -NoProfile -WindowStyle Hidden -Command \"& '{quoted}' machine inspect *> $null; if ($LASTEXITCODE -ne 0) {{ & '{quoted}' machine init --now }} else {{ & '{quoted}' machine start }}\""; return [ - new ProcessSpec { FileName = podman, Arguments = init ? ["machine", "init", "--now"] : ["machine", "start"], Timeout = TimeSpan.FromMinutes(30) }, - new ProcessSpec { FileName = "reg.exe", Arguments = ["add", @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Podman", "/t", "REG_SZ", "/d", startAtSignIn, "/f"] } + new ProcessSpec { FileName = "reg.exe", Arguments = ["add", @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Podman", "/t", "REG_SZ", "/d", atSignIn, "/f"] }, + new ProcessSpec { FileName = podman, Arguments = init ? ["machine", "init", "--now"] : ["machine", "start"], Timeout = TimeSpan.FromMinutes(30) } ]; } + public static ToolAction AfterWslRestart(ToolAction action) => action with + { + Commands = [.. action.Commands.Where(command => command.Arguments.FirstOrDefault() is not "machine")], + AfterInstall = "Podman sets up its machine when you next sign in, after the restart WSL needs." + }; + private static ToolAction? Colima(ToolContext context) { if (context.PackageManagers.Brew is not { } brew) diff --git a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs index 4bdf0f399ea..c3d14d8f08c 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ProjectStepTests.cs @@ -865,7 +865,7 @@ public async Task GitHubSignIn_Should_SetUpGitFirstSoGhAsksNothingButTheCode() [TestMethod] public async Task GitHubSignIn_Should_ShowTheCodeAndLetEscSkipIt() { - using var host = new TestHost(); + using var host = new TestHost(HostOs.Windows); var project = CreateFakeProject(host, "Contoso"); host.Runner.Executables["gh"] = "/usr/bin/gh"; var stopped = false; diff --git a/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs b/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs index a879d3df624..77d79b5a6bd 100644 --- a/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs +++ b/src/Cli/Tests/Bit.Cli.Tests/ToolTests.cs @@ -105,11 +105,11 @@ public async Task Podman_Should_CountAsTheContainerRuntime() var withoutMachine = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker"); Assert.AreEqual(ToolState.NotRunning, withoutMachine.Status.State); Assert.AreEqual("Start Podman", withoutMachine.Action!.Title); - CollectionAssert.AreEqual(new[] { "machine", "init", "--now" }, withoutMachine.Action.Commands[0].Arguments.ToArray()); + CollectionAssert.AreEqual(new[] { "machine", "init", "--now" }, withoutMachine.Action.Commands[^1].Arguments.ToArray()); host.Runner.On("podman", "machine inspect", 0, "[{}]"); var stopped = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker"); - CollectionAssert.AreEqual(new[] { "machine", "start" }, stopped.Action!.Commands[0].Arguments.ToArray()); + CollectionAssert.AreEqual(new[] { "machine", "start" }, stopped.Action!.Commands[^1].Arguments.ToArray()); } [TestMethod] @@ -450,9 +450,16 @@ public async Task WindowsInstalls_Should_UseWinget() Assert.AreEqual("Install Podman", docker.Title); Assert.AreEqual(Elevation.None, docker.Elevation, "Podman installs for the user, so it needs no administrator prompt of its own."); CollectionAssert.IsSubsetOf(new[] { "install", "--id", "Podman.CLI", "--silent" }, docker.Commands[0].Arguments.ToArray()); - CollectionAssert.AreEqual(new[] { "machine", "init", "--now" }, docker.Commands[1].Arguments.ToArray()); - StringAssert.EndsWith(docker.Commands[1].FileName, @"Programs\Podman\podman.exe"); - CollectionAssert.Contains(docker.Commands[2].Arguments.ToArray(), @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"); + CollectionAssert.Contains(docker.Commands[1].Arguments.ToArray(), @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"); + StringAssert.Contains(docker.Commands[1].Arguments[^2], "machine init --now", "Signing in creates the machine when there is none yet."); + CollectionAssert.AreEqual(new[] { "machine", "init", "--now" }, docker.Commands[2].Arguments.ToArray()); + StringAssert.EndsWith(docker.Commands[2].FileName, @"Programs\Podman\podman.exe"); + + host.Runner.On("wsl", "--status", 1); + var afterWsl = (await CheckAsync(host, new ToolNeeds { Aspire = true })).Single(c => c.Tool.Id == "docker").Action!; + Assert.IsFalse(afterWsl.Commands.Any(c => c.Arguments.FirstOrDefault() is "machine"), "WSL needs a restart before Podman can create its machine."); + StringAssert.Contains(afterWsl.AfterInstall, "next sign in"); + host.Runner.On("wsl", "--status", 0); Assert.IsTrue(docker.Optional, "A project that runs without its containers doesn't fail on a missing container runtime."); Assert.IsFalse((await CheckAsync(host, new ToolNeeds { Aspire = true, ContainersRequired = true })).Single(c => c.Tool.Id == "docker").Action!.Optional); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md index d3f3c633749..cdad47afa57 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md @@ -11,7 +11,7 @@ The work is yours to judge. Ask the user only what the code can't answer and wha ## 1. Know where the project stands -- `bit.json` records the bit Boilerplate version the project comes from, its name and the value of every option it was created with. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. When they disagree, the project may have been upgraded before without updating them; the package versions of the bit platform libraries in `src/Directory.Packages.props` tell which one is right. +- `bit.json` records the bit Boilerplate version the project comes from, its name and the value of every option it was created with. The MCP server configurations (`.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.junie/mcp/mcp.json`, `.gemini/settings.json`) pin the bit platform MCP server to a version too. `bit.json` is the one to go by: a team may move those, or the bit platform packages, without upgrading the template. - Check the recorded options against the code. The team may have turned something off after creation, switched the database provider, or removed a module, and the diff should be made with the options the project has today. - The target is the version the user names. Without one, it's the newest stable bit Boilerplate; take a prerelease only when the user asks. The release notes on GitHub for every version in between say why things changed, and the bit platform MCP server answers questions about any of them. - Start from a clean working tree on a new branch, so the whole upgrade can be reviewed as one change. If there are uncommitted changes, ask the user whether to commit or stash them first. @@ -45,7 +45,7 @@ Some changes need more than the text of the diff: - In `bit.json`, the version, so the next upgrade starts from here; keep the options the project really has. - The version in the bit platform MCP server URL in every MCP configuration listed above, and in any other agent configuration that pins it. -- Search the project for the old version string and update every place that names the template or bit platform version, such as the package versions of the bit platform libraries. Leave other packages that happen to share the number alone. +- Search the project for the old version string and update every place that names the template or bit platform version, such as the package versions of the bit platform libraries. Leave a package the team pinned on purpose, and other packages that happen to share the number, alone. ## 5. Prove it diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs index 31d3d2447c5..5b9c6bb9a3c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs @@ -198,8 +198,12 @@ private async Task LoadDeploymentConfiguration() request.Options.Set(new HttpRequestOptionsKey(RequestOptionNames.AllowedStatusCodes), [HttpStatusCode.ServiceUnavailable]); using var response = await httpClient.SendAsync(request, CurrentCancellationToken); + var report = await response.Content.ReadFromJsonAsync(JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken); - return await response.Content.ReadFromJsonAsync(JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken); + if (response.IsSuccessStatusCode is false && report?.Entries is not { Count: > 0 }) + throw new HttpRequestException($"{url} answered {(int)response.StatusCode} without a health report.", null, response.StatusCode); + + return report; } //#if (api == "Standalone") diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs index 6668bd5fcdb..2fa13fed780 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs @@ -123,6 +123,7 @@ public async Task DetailedReport_Should_ShowEveryCheckHealthyOrNotConfigured(str var report = await response.Content.ReadFromJsonAsync(JsonSerializerOptions.Web, TestContext.CancellationToken); Assert.IsNotNull(report, $"{host}healthz returned no report."); + Assert.Contains("binStorage", report.Entries.Keys, $"{host}healthz answered {(int)response.StatusCode} without the checks a report has."); failures.AddRange(report.Entries .Where(entry => entry.Value.Status is not HealthCheckStatus.Healthy From 142e30323b012f42caf004d53c250843eee2f7a8 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 10:57:00 +0200 Subject: [PATCH 11/12] fix(templates): keep bit.json out of the solution files #13974 --- src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln | 1 - src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln index 0d503aed874..b9c17a0da5b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln +++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.sln @@ -25,7 +25,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".SolutionItems", ".Solution vs-spell.dic = vs-spell.dic .vscode\mcp.json=.vscode\mcp.json README.md = README.md - bit.json = bit.json EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Client", "Client", "{248D8229-BABD-4F0A-A9C6-0417B464507B}" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx index 49fd60136c7..8d0e43dff38 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx @@ -14,7 +14,6 @@ - From 6c29557017a412a414ab9cf7392645d13b315ded Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Tue, 20 Oct 2026 11:13:29 +0200 Subject: [PATCH 12/12] feat(templates): save edited images as WebP #13974 Co-Authored-By: Claude Opus 5.5 --- .../Components/Common/ImageEditorModal.razor.cs | 11 ++--------- .../Pages/Products/AddOrEditProductPage.razor.cs | 2 +- .../Components/Pages/Settings/ProfileSection.razor.cs | 2 +- .../Infrastructure/Services/ImageEditorService.cs | 7 +++---- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/ImageEditorModal.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/ImageEditorModal.razor.cs index a87537a1a3b..e3baab25819 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/ImageEditorModal.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Common/ImageEditorModal.razor.cs @@ -16,8 +16,6 @@ public partial class ImageEditorModal [Parameter] public string FileName { get; set; } = default!; - [Parameter] public string? ContentType { get; set; } - [Parameter] public double? CropAspectRatio { get; set; } [Parameter] public int MinWidth { get; set; } @@ -190,14 +188,9 @@ private async Task Save() await editor.ApplyCropAsync(); } - var exportOptions = ContentType is "image/jpeg" ? ImageExportOptions.Jpeg with { Quality = 0.92 } : ImageExportOptions.Png; - - var image = await editor.ExportAsync(exportOptions); + var format = editor.BrowserCapabilities?.ChooseEncodableFormat([ImageFormat.WebP]) ?? ImageFormat.Jpeg; - if (image is not null && image.Length > MaxFileSize && image.Format is not ImageFormat.Jpeg) - { - image = await editor.ExportAsync(ImageExportOptions.Jpeg with { Quality = 0.92, MaxFileSize = MaxFileSize }); - } + var image = await editor.ExportAsync(new ImageExportOptions { Format = format, Quality = 0.92, MaxFileSize = MaxFileSize }); if (image is null) return; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Products/AddOrEditProductPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Products/AddOrEditProductPage.razor.cs index 6dfdae746b3..d15a8d0f78f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Products/AddOrEditProductPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Products/AddOrEditProductPage.razor.cs @@ -112,7 +112,7 @@ private async Task HandleOnFilesChange(BitFileInfo[] files) if (files.FirstOrDefault(file => file.Status is BitFileUploadStatus.Pending) is not { PreviewUrl: not null } file) return; - var result = await imageEditorService.Edit(file.PreviewUrl, file.Name, file.ContentType, cropAspectRatio: null, minWidth: 512, minHeight: 512); + var result = await imageEditorService.Edit(file.PreviewUrl, file.Name, cropAspectRatio: null, minWidth: 512, minHeight: 512); if (result.Outcome is ImageEditorOutcome.KeepOriginal) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Settings/ProfileSection.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Settings/ProfileSection.razor.cs index ce367bd5ab8..292db084516 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Settings/ProfileSection.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Settings/ProfileSection.razor.cs @@ -86,7 +86,7 @@ private async Task HandleOnFilesChange(BitFileInfo[] files) if (files.FirstOrDefault(file => file.Status is BitFileUploadStatus.Pending) is not { PreviewUrl: not null } file) return; - var result = await imageEditorService.Edit(file.PreviewUrl, file.Name, file.ContentType, cropAspectRatio: 1, minWidth: 256, minHeight: 256); + var result = await imageEditorService.Edit(file.PreviewUrl, file.Name, cropAspectRatio: 1, minWidth: 256, minHeight: 256); if (result.Outcome is ImageEditorOutcome.KeepOriginal) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/ImageEditorService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/ImageEditorService.cs index 5a39df162a4..50dacd5a0bc 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/ImageEditorService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/ImageEditorService.cs @@ -10,11 +10,11 @@ public partial class ImageEditorService [AutoInject] private BitModalService modalService = default!; [AutoInject] private LazyAssemblyLoader lazyAssemblyLoader = default!; - public async Task Edit(string imageUrl, string fileName, string? contentType, double? cropAspectRatio, int minWidth, int minHeight) + public async Task Edit(string imageUrl, string fileName, double? cropAspectRatio, int minWidth, int minHeight) { await lazyAssemblyLoader.LoadAssembliesAsync(["Bit.Bmage.Core.wasm", "Bit.Bmage.wasm", "Bit.Bmage.Editor.wasm"]); - return await ShowEditor(imageUrl, fileName, contentType, cropAspectRatio, minWidth, minHeight); + return await ShowEditor(imageUrl, fileName, cropAspectRatio, minWidth, minHeight); } public async Task Upload(ImageEditorResult result, string uploadUrl, CancellationToken cancellationToken) @@ -31,7 +31,7 @@ public async Task Edit(string imageUrl, string fileName, stri return string.IsNullOrEmpty(responseText) ? null : responseText; } - private async Task ShowEditor(string imageUrl, string fileName, string? contentType, double? cropAspectRatio, int minWidth, int minHeight) + private async Task ShowEditor(string imageUrl, string fileName, double? cropAspectRatio, int minWidth, int minHeight) { TaskCompletionSource tcs = new(); BitModalReference? modalReference = null; @@ -46,7 +46,6 @@ void Complete(ImageEditorResult result) { { nameof(ImageEditorModal.ImageUrl), imageUrl }, { nameof(ImageEditorModal.FileName), fileName }, - { nameof(ImageEditorModal.ContentType), contentType ?? string.Empty }, { nameof(ImageEditorModal.MinWidth), minWidth }, { nameof(ImageEditorModal.MinHeight), minHeight }, { nameof(ImageEditorModal.OnResult), (Action)Complete }