From 7a677901c3745f77513de38ddf24ac71adf1f39f Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 20 Jul 2026 15:53:29 +0200 Subject: [PATCH 1/4] Add run command to launch a Stage sandbox from .play files The command discovers the Screenplay (.play) files in a folder and hands them to the cratis/stage Docker container with the folder mounted and the Stage API published on the host. It refuses to start when no .play files are present, and reports a clear error when Docker is unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Cli/Commands/Run/PlayFiles.cs | 25 ++++++++ Source/Cli/Commands/Run/RunCommand.cs | 74 +++++++++++++++++++++++ Source/Cli/Commands/Run/RunSettings.cs | 33 ++++++++++ Source/Cli/Commands/Run/StageContainer.cs | 44 ++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 Source/Cli/Commands/Run/PlayFiles.cs create mode 100644 Source/Cli/Commands/Run/RunCommand.cs create mode 100644 Source/Cli/Commands/Run/RunSettings.cs create mode 100644 Source/Cli/Commands/Run/StageContainer.cs diff --git a/Source/Cli/Commands/Run/PlayFiles.cs b/Source/Cli/Commands/Run/PlayFiles.cs new file mode 100644 index 0000000..cafdc3b --- /dev/null +++ b/Source/Cli/Commands/Run/PlayFiles.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Discovers Screenplay (.play) files within a folder tree. +/// +public static class PlayFiles +{ + /// + /// The glob pattern that identifies a Screenplay file. + /// + public const string SearchPattern = "*.play"; + + /// + /// Determines whether the given folder contains at least one Screenplay (.play) file, + /// searching recursively through all subfolders. + /// + /// The folder to search. + /// True if one or more .play files are present; otherwise false. + public static bool ExistIn(string path) => + Directory.Exists(path) && + Directory.EnumerateFiles(path, SearchPattern, SearchOption.AllDirectories).Any(); +} diff --git a/Source/Cli/Commands/Run/RunCommand.cs b/Source/Cli/Commands/Run/RunCommand.cs new file mode 100644 index 0000000..abb2c47 --- /dev/null +++ b/Source/Cli/Commands/Run/RunCommand.cs @@ -0,0 +1,74 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.ComponentModel; +using System.Diagnostics; + +namespace Cratis.Cli.Commands.Run; + +/// +/// Runs the Screenplay (.play) files in a folder in a local Stage sandbox using Docker. +/// +[LlmDescription("Runs the current folder's Screenplay (.play) files in a local Stage sandbox via Docker. Errors if no .play files are present. The Stage API is published on the host (default port 9090).")] +[CliCommand("run", "Run the Screenplay (.play) files in the current folder in a local Stage sandbox")] +[CliExample("run")] +[CliExample("run", "./screenplays")] +[CliExample("run", "--port", "9191")] +[LlmOption("--tag", "string", "The cratis/stage image tag to run (default: latest).")] +[LlmOption("--port", "int", "Host port to publish the Stage API on (default: 9090).")] +public class RunCommand : AsyncCommand +{ + /// + protected override async Task ExecuteAsync(CommandContext context, RunSettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + var path = Path.GetFullPath(settings.Path ?? Directory.GetCurrentDirectory()); + + if (!Directory.Exists(path)) + { + OutputFormatter.WriteError(format, $"Folder '{path}' does not exist", "Run this command from a folder that contains one or more .play files, or pass the path to one", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + if (!PlayFiles.ExistIn(path)) + { + OutputFormatter.WriteError(format, "No Screenplay files (.play) found in the folder", "Run this command from a folder that contains one or more .play files, or pass the path to one", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + + var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port); + var startInfo = new ProcessStartInfo { FileName = "docker" }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal) && + !string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) && + !string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal)) + { + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Starting Stage from {path.EscapeMarkup()} on http://localhost:{settings.Port}[/]"); + } + + Process? process; + try + { + process = Process.Start(startInfo); + } + catch (Win32Exception) + { + OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode); + return ExitCodes.ConnectionError; + } + + if (process is null) + { + OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode); + return ExitCodes.ConnectionError; + } + + await process.WaitForExitAsync(cancellationToken); + + return process.ExitCode == 0 ? ExitCodes.Success : ExitCodes.ServerError; + } +} diff --git a/Source/Cli/Commands/Run/RunSettings.cs b/Source/Cli/Commands/Run/RunSettings.cs new file mode 100644 index 0000000..ca5a25f --- /dev/null +++ b/Source/Cli/Commands/Run/RunSettings.cs @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Settings for the run command. +/// +public class RunSettings : GlobalSettings +{ + /// + /// Gets or sets the folder containing the Screenplay files to run. Defaults to the current directory. + /// + [CommandArgument(0, "[PATH]")] + [Description("Folder containing the Screenplay (.play) files to run. Defaults to the current directory.")] + public string? Path { get; set; } + + /// + /// Gets or sets the cratis/stage image tag to run. + /// + [CommandOption("--tag ")] + [Description("The cratis/stage image tag to run.")] + [DefaultValue("latest")] + public string Tag { get; set; } = "latest"; + + /// + /// Gets or sets the host port to publish the Stage API on. + /// + [CommandOption("--port ")] + [Description("Host port to publish the Stage API on.")] + [DefaultValue(9090)] + public int Port { get; set; } = 9090; +} diff --git a/Source/Cli/Commands/Run/StageContainer.cs b/Source/Cli/Commands/Run/StageContainer.cs new file mode 100644 index 0000000..cc97293 --- /dev/null +++ b/Source/Cli/Commands/Run/StageContainer.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Describes the Stage Docker container the run command launches and builds its invocation arguments. +/// +public static class StageContainer +{ + /// + /// The Docker image name for the Stage sandbox. + /// + public const string Image = "cratis/stage"; + + /// + /// The port the Stage API listens on inside the container. + /// + public const int ApiPort = 9090; + + /// + /// The path inside the container the folder of Screenplay files is mounted at. + /// + public const string MountPath = "/eventmodel"; + + /// + /// Builds the argument list for docker run that launches the Stage container with the given + /// folder mounted and the Stage API published on the host. + /// + /// The absolute path to the folder of Screenplay files to mount. + /// The image tag to run. + /// The host port to publish the Stage API on. + /// The ordered argument list to pass to the docker executable. + public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort) => + [ + "run", + "--rm", + "-p", + $"{hostPort}:{ApiPort}", + "-v", + $"{path}:{MountPath}", + $"{Image}:{tag}" + ]; +} From 2f76017576d849b481604af2adcd9cb1d1f75bbb Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 20 Jul 2026 15:53:29 +0200 Subject: [PATCH 2/4] Add specs for run command play-file discovery and container args Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Cli.Specs/Usings.cs | 1 + .../for_PlayFiles/given/a_temporary_folder.cs | 19 +++++++++++++++++++ .../and_a_play_file_is_nested.cs | 19 +++++++++++++++++++ .../and_a_play_file_is_present.cs | 15 +++++++++++++++ .../and_no_play_files_are_present.cs | 15 +++++++++++++++ .../and_the_folder_does_not_exist.cs | 16 ++++++++++++++++ .../with_a_custom_host_port.cs | 14 ++++++++++++++ .../with_matching_ports.cs | 17 +++++++++++++++++ 8 files changed, 116 insertions(+) create mode 100644 Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs create mode 100644 Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs create mode 100644 Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs create mode 100644 Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs create mode 100644 Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs create mode 100644 Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs create mode 100644 Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs index efc5842..73859ae 100644 --- a/Source/Cli.Specs/Usings.cs +++ b/Source/Cli.Specs/Usings.cs @@ -7,5 +7,6 @@ global using Cratis.Cli.Commands.Chronicle.Observers; global using Cratis.Cli.Commands.Completions; global using Cratis.Cli.Commands.Init; +global using Cratis.Cli.Commands.Run; global using Cratis.Specifications; global using Xunit; diff --git a/Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs b/Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs new file mode 100644 index 0000000..a9eced1 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_PlayFiles.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs new file mode 100644 index 0000000..376dd99 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files; + +public class and_a_play_file_is_nested : given.a_temporary_folder +{ + bool _result; + + void Establish() + { + var nested = Directory.CreateDirectory(Path.Combine(_folder, "features", "invoicing")).FullName; + File.WriteAllText(Path.Combine(nested, "invoicing.play"), "module Invoicing"); + } + + void Because() => _result = PlayFiles.ExistIn(_folder); + + [Fact] void should_find_the_nested_play_file() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs new file mode 100644 index 0000000..358e785 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files; + +public class and_a_play_file_is_present : given.a_temporary_folder +{ + bool _result; + + void Establish() => File.WriteAllText(Path.Combine(_folder, "demo.play"), "module Demo"); + + void Because() => _result = PlayFiles.ExistIn(_folder); + + [Fact] void should_find_the_play_file() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs new file mode 100644 index 0000000..8887a25 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files; + +public class and_no_play_files_are_present : given.a_temporary_folder +{ + bool _result; + + void Establish() => File.WriteAllText(Path.Combine(_folder, "readme.md"), "not a play file"); + + void Because() => _result = PlayFiles.ExistIn(_folder); + + [Fact] void should_not_find_any_play_files() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs new file mode 100644 index 0000000..fbbf5a6 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files; + +public class and_the_folder_does_not_exist : Specification +{ + string _folder; + bool _result; + + void Establish() => _folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + void Because() => _result = PlayFiles.ExistIn(_folder); + + [Fact] void should_not_find_any_play_files() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs new file mode 100644 index 0000000..2cdfe2f --- /dev/null +++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageContainer.when_building_run_arguments; + +public class with_a_custom_host_port : Specification +{ + IReadOnlyList _arguments; + + void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191); + + [Fact] void should_publish_the_custom_host_port_to_the_api_port() => _arguments.ShouldContain("9191:9090"); + [Fact] void should_run_the_requested_tag() => _arguments.ShouldContain("cratis/stage:1.2.0"); +} diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs new file mode 100644 index 0000000..b1d52f7 --- /dev/null +++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageContainer.when_building_run_arguments; + +public class with_matching_ports : Specification +{ + IReadOnlyList _arguments; + + void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090); + + [Fact] void should_run_a_container() => _arguments.ShouldContain("run"); + [Fact] void should_remove_the_container_on_exit() => _arguments.ShouldContain("--rm"); + [Fact] void should_publish_the_host_port_to_the_api_port() => _arguments.ShouldContain("9090:9090"); + [Fact] void should_mount_the_folder_at_the_model_path() => _arguments.ShouldContain("/work/space:/eventmodel"); + [Fact] void should_run_the_tagged_image() => _arguments.ShouldContain("cratis/stage:latest"); +} From d6b06b278a2f11d4c00b17628f1fc07be0e58dc9 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 20 Jul 2026 15:53:29 +0200 Subject: [PATCH 3/4] Document the run command Co-Authored-By: Claude Opus 4.8 (1M context) --- Documentation/reference/run.md | 51 +++++++++++++++++++++++++++++++++ Documentation/reference/toc.yml | 2 ++ 2 files changed, 53 insertions(+) create mode 100644 Documentation/reference/run.md diff --git a/Documentation/reference/run.md b/Documentation/reference/run.md new file mode 100644 index 0000000..3c9a7ce --- /dev/null +++ b/Documentation/reference/run.md @@ -0,0 +1,51 @@ +# Run + +`cratis run` boots a local [Stage](https://github.com/Cratis/Stage) sandbox from the Screenplay (`.play`) files in a folder. It packages a Chronicle kernel, the Stage engine, and an in-memory event store into a single throwaway container so you can play with an event model straight from its `.play` source — no server to set up, nothing to clean up afterward. + +```bash +cratis run [PATH] +``` + +You point it at a folder of `.play` files (or run it from inside one), and it hands that folder to the Stage container, which compiles every `.play` file it finds and exposes a live API you can drive. + +## Prerequisites + +- **Docker** must be installed and the `docker` command on your `PATH`. `cratis run` shells out to `docker run`. +- The `cratis/stage` image is pulled automatically on first use. + +## Arguments + +| Argument | Description | +|---|---| +| `PATH` | Folder containing the Screenplay (`.play`) files to run. Searched recursively. Defaults to the current directory. | + +## Options + +| Option | Description | +|---|---| +| `--tag ` | The `cratis/stage` image tag to run. Default: `latest`. | +| `--port ` | Host port to publish the Stage API on. Default: `9090`. | + +Global options such as `-o/--output` are also accepted — see [Global Options](global-options.md). + +## What it does + +The command mounts the folder into the Stage container and publishes the Stage API to your host: + +```bash +docker run --rm -p 9090:9090 -v "$PWD":/eventmodel cratis/stage:latest +``` + +- The folder is mounted at `/eventmodel` inside the container; Stage globs `**/*.play` beneath it and compiles them. +- The Stage API is published on `http://localhost:9090` (change the host side with `--port`). +- `--rm` removes the container when it exits, so every run starts from a clean, in-memory store. + +The command streams the container's output and exits with the container's exit code. Stop the session with `Ctrl+C`. + +## Errors + +| Condition | Result | +|---|---| +| The folder contains no `.play` files | Validation error — nothing is started. | +| The folder does not exist | Validation error. | +| `docker` is not installed or not on `PATH` | Connection error with a hint to install Docker. | diff --git a/Documentation/reference/toc.yml b/Documentation/reference/toc.yml index c6b9bab..e1db2b3 100644 --- a/Documentation/reference/toc.yml +++ b/Documentation/reference/toc.yml @@ -1,3 +1,5 @@ +- name: Run + href: run.md - name: Global Options href: global-options.md - name: Output Formats From 8ca237a907edef9ad154718b0f5fc232ea0d1e3f Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 20 Jul 2026 18:53:44 +0200 Subject: [PATCH 4/4] Override transitive AngleSharp to a patched version SharpConsoleUI pulled AngleSharp 1.4.0, which carries the moderate advisory GHSA-pgww-w46g-26qg and failed the Release build under warnings-as-errors (NU1902). Pin AngleSharp to 1.5.2, which is not affected. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 2 ++ Source/Cli/Cli.csproj | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0e97cde..912f2ef 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,8 @@ + + diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 219ff3f..70dc0e3 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -25,6 +25,8 @@ + +