diff --git a/.github/agents/test-platform.md b/.github/agents/test-platform.md index 9f65e2f2adb..ef2939b4984 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 @@ -146,7 +148,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 +409,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 +419,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 +458,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. @@ -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 `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. +- `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 `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. 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/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..43ddc455e8e --- /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 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." }; + + 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 bit.json 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..604f8747929 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 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/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..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"; @@ -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) @@ -409,6 +416,8 @@ await steps.RunAsync("create", $"Creating {context.Name}", async (progress, ct) 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) ? ReadSeedPassword(File.ReadAllText(readme)) : null; if (result.Succeeded && File.Exists(context.Solution) && File.Exists(legacySolution)) { @@ -466,7 +475,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 +528,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"); } @@ -559,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/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..2cf8984434f 100644 --- a/src/Cli/Bit.Cli/Projects/ProjectSteps.cs +++ b/src/Cli/Bit.Cli/Projects/ProjectSteps.cs @@ -7,11 +7,23 @@ using Bit.Cli.Templates; using Bit.Cli.Tools; using Bit.Cli.Trust; +using Spectre.Console; 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 +295,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 +315,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))}")); + + 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>(); - return StepResult.Succeeded("Started and stopped the project with Aspire", "its images and builds are ready, so the IDE starts it fast"); + 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 +534,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 +549,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 +573,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 +624,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) @@ -521,7 +717,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"); @@ -529,34 +725,59 @@ 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(); + + 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) + { + 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 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); + return extensions; } public static IReadOnlyList ReadRecommendedExtensions(string projectDirectory) @@ -651,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(); + + try + { + var result = await login; - 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"); + 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) @@ -752,4 +1015,13 @@ 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(); + + [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..b499d508ad2 --- /dev/null +++ b/src/Cli/Bit.Cli/Projects/TemplateDiff.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +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); + +public sealed 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 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); + + 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($"{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) + { + 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 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()) + { + 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, 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, cancellationToken); + } + + if (steps.AnyFailed) + return CliApp.ExitFailed; + + 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(); + 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 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, 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); + 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) + { + 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]); + } + + return (arguments, unknown); + } + + public static ProjectRecord ReadProject(string directory) + { + var bitJson = Path.Combine(directory, "bit.json"); + + return File.Exists(bitJson) && ReadBitJson(File.ReadAllText(bitJson)) is { } record + ? record + : new ProjectRecord(null, null, new Dictionary()); + } + + 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); + } + catch (JsonException) + { + return null; + } + } + + 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; + } +} 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..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)); @@ -509,7 +514,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 +524,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 +575,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 +608,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 +620,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 +637,64 @@ 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.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 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 = "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) + 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) } + ] }; } - public override string? ManualInstructions(ToolContext context) => context.Environment.IsLinux ? "https://docs.docker.com/engine/install/" : "https://www.docker.com/products/docker-desktop/"; + 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..723fba266d5 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -20,18 +20,19 @@ 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. 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 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. +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. 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. @@ -87,7 +88,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 +100,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 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. 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. + +The project's `upgrade` skill, for the AI coding tools, runs it and applies the changes to your code. ## In CI @@ -120,7 +139,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. @@ -254,9 +273,10 @@ 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. -- **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..ea7e10594c1 --- /dev/null +++ b/src/Cli/Tests/Bit.Cli.Tests/DiffTests.cs @@ -0,0 +1,52 @@ +using Bit.Cli.Projects; +using Bit.Cli.Templates; + +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 } + } + """); + + 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.IsNull(TemplateDiff.ReadProject(host.WorkingDirectory).Version); + } + + [TestMethod] + public void TheSummary_Should_ShowThePasswordTheReadmeNames() + { + Assert.AreEqual("40817263", NewWorkflow.ReadSeedPassword("pre-confirmed accounts whose password is **`40817263`**:")); + Assert.IsNull(NewWorkflow.ReadSeedPassword("## Welcome!")); + } + + [TestMethod] + public void EachVersion_Should_GetOnlyTheOptionsItHas() + { + var options = new Dictionary { ["database"] = "postgresql", ["madeUp"] = "true", ["module"] = "Nope" }; + + var (arguments, unknown) = TemplateDiff.TemplateArguments(TemplateManifest.Embedded, options); + + CollectionAssert.AreEqual(new[] { "--database", "PostgreSQL" }, arguments); + CollectionAssert.AreEquivalent(new[] { "--madeUp true", "--module Nope" }, unknown); + } +} 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..c3d14d8f08c 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() { @@ -529,52 +630,71 @@ 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() { 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); @@ -583,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() { @@ -732,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(HostOs.Windows); + 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() { @@ -758,6 +922,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..77d79b5a6bd 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"] = "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"); + 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[^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[^1].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,21 @@ 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.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); 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..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 @@ -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 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 `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 +- 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..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. --- @@ -713,7 +701,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..cdad47afa57 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/agents/upgrade.agent.md @@ -0,0 +1,67 @@ +--- +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 + +- `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. +- 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` 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 + +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 `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 a package the team pinned on purpose, and 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. + +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/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index 39f1b43237e..de6e12624c6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -40,10 +40,20 @@ "format": "yyyy-MM-dd" } }, + "SeedPassword": { + "type": "generated", + "generator": "random", + "parameters": { + "low": 10000000, + "high": 99999999 + }, + "replaces": "12345678" + }, "database": { "displayName": "Backend database", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-database", "defaultValue": "Sqlite", "choices": [ { @@ -73,6 +83,7 @@ "displayName": "Files storage", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-filesStorage", "defaultValue": "Local", "choices": [ { @@ -98,6 +109,7 @@ "displayName": "API deployment configuration", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-api", "defaultValue": "Integrated", "choices": [ { @@ -114,6 +126,7 @@ "displayName": "CI-CD pipeline", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-pipeline", "defaultValue": "GitHub", "choices": [ { @@ -134,6 +147,7 @@ "displayName": "Module", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-module", "defaultValue": "None", "choices": [ { @@ -154,6 +168,7 @@ "displayName": "Multi-Tenant", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-multitenant", "defaultValue": "true", "description": "Adds multi-tenant support" }, @@ -161,6 +176,7 @@ "displayName": "Captcha", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-captcha", "defaultValue": "None", "choices": [ { @@ -177,6 +193,7 @@ "displayName": "Add aspire?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-aspire", "defaultValue": "true", "description": "Adds aspire" }, @@ -184,6 +201,7 @@ "displayName": "Add redis?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-redis", "defaultValue": "false", "description": "Adds redis" }, @@ -191,6 +209,7 @@ "displayName": "Add push notification?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-notification", "defaultValue": "true", "description": "Adds push notification." }, @@ -198,6 +217,7 @@ "displayName": "Sample", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-sample", "defaultValue": "false", "description": "Adds todo page sample." }, @@ -205,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." }, @@ -224,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." }, @@ -231,6 +255,7 @@ "displayName": "Add cloudflare CDN integration?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-cloudflare", "defaultValue": "true", "description": "Adds cloudflare CDN integration." }, @@ -238,6 +263,7 @@ "displayName": "Add Ads?", "type": "parameter", "datatype": "bool", + "replaces": "bit-option-ads", "defaultValue": "false", "description": "Adds GoogleAds integration." }, @@ -245,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." }, @@ -252,6 +279,7 @@ "displayName": "Theme", "type": "parameter", "datatype": "choice", + "replaces": "bit-option-theme", "defaultValue": "Fluent2", "choices": [ { @@ -674,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/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..54ff250eff1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/README.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/README.md @@ -2,104 +2,18 @@ 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 (`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/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" + } +} 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/Management/OperationsPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/OperationsPage.razor.cs index df1b39acdb3..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 @@ -1,4 +1,5 @@ //+:cnd:noEmit +using System.Net; using System.Text; using Boilerplate.Shared.Features.Diagnostic; @@ -191,8 +192,19 @@ 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); + var report = 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/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/HttpMessageHandlers/ExceptionDelegatingHandler.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs index ed8cc83fd58..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,13 @@ 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)) + { + 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/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 } 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/HealthChecks/HealthCheckTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/HealthChecks/HealthCheckTests.cs index 0f284146289..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 @@ -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,9 +116,14 @@ 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."); + 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 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/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..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 @@ -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", Guid.Parse("AC87AA5B-4B37-4E52-8468-2D5DF24AF256")); + + /// 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 = projectId.ToByteArray(); + 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..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 = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + 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 = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + 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 = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + 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 98abeecd744..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, "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, "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 8e9c1abf3da..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 = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + 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 = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + 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 = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", + 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/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.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/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/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..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 @@ -9,9 +9,9 @@ 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))] + [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/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/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!; 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/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 } 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/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..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 @@ -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) @@ -447,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() @@ -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); }