Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<PackageVersion Include="Spectre.Console" Version="0.57.2" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.55.0" />
<PackageVersion Include="SharpConsoleUI" Version="2.5.11" />
<!-- Pin the SharpConsoleUI-transitive AngleSharp to a version without GHSA-pgww-w46g-26qg (NU1902). -->
<PackageVersion Include="AngleSharp" Version="1.5.2" />
<PackageVersion Include="Grpc.Net.Client" Version="2.80.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<!-- Source Generator -->
Expand Down
51 changes: 51 additions & 0 deletions Documentation/reference/run.md
Original file line number Diff line number Diff line change
@@ -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 <TAG>` | The `cratis/stage` image tag to run. Default: `latest`. |
| `--port <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. |
2 changes: 2 additions & 0 deletions Documentation/reference/toc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- name: Run
href: run.md
- name: Global Options
href: global-options.md
- name: Output Formats
Expand Down
1 change: 1 addition & 0 deletions Source/Cli.Specs/Usings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
19 changes: 19 additions & 0 deletions Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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<string> _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");
}
Original file line number Diff line number Diff line change
@@ -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<string> _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");
}
2 changes: 2 additions & 0 deletions Source/Cli/Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
<PackageReference Include="Spectre.Console" />
<PackageReference Include="Spectre.Console.Cli" />
<PackageReference Include="SharpConsoleUI" />
<!-- Override SharpConsoleUI's transitive AngleSharp to a version without GHSA-pgww-w46g-26qg (NU1902). -->
<PackageReference Include="AngleSharp" />
<PackageReference Include="Grpc.Net.Client" />
</ItemGroup>

Expand Down
25 changes: 25 additions & 0 deletions Source/Cli/Commands/Run/PlayFiles.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Discovers Screenplay (<c>.play</c>) files within a folder tree.
/// </summary>
public static class PlayFiles
{
/// <summary>
/// The glob pattern that identifies a Screenplay file.
/// </summary>
public const string SearchPattern = "*.play";

/// <summary>
/// Determines whether the given folder contains at least one Screenplay (<c>.play</c>) file,
/// searching recursively through all subfolders.
/// </summary>
/// <param name="path">The folder to search.</param>
/// <returns>True if one or more <c>.play</c> files are present; otherwise false.</returns>
public static bool ExistIn(string path) =>
Directory.Exists(path) &&
Directory.EnumerateFiles(path, SearchPattern, SearchOption.AllDirectories).Any();
}
74 changes: 74 additions & 0 deletions Source/Cli/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Runs the Screenplay (.play) files in a folder in a local Stage sandbox using Docker.
/// </summary>
[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<RunSettings>
{
/// <inheritdoc/>
protected override async Task<int> 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;
}
}
33 changes: 33 additions & 0 deletions Source/Cli/Commands/Run/RunSettings.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Settings for the run command.
/// </summary>
public class RunSettings : GlobalSettings
{
/// <summary>
/// Gets or sets the folder containing the Screenplay files to run. Defaults to the current directory.
/// </summary>
[CommandArgument(0, "[PATH]")]
[Description("Folder containing the Screenplay (.play) files to run. Defaults to the current directory.")]
public string? Path { get; set; }

/// <summary>
/// Gets or sets the cratis/stage image tag to run.
/// </summary>
[CommandOption("--tag <TAG>")]
[Description("The cratis/stage image tag to run.")]
[DefaultValue("latest")]
public string Tag { get; set; } = "latest";

/// <summary>
/// Gets or sets the host port to publish the Stage API on.
/// </summary>
[CommandOption("--port <PORT>")]
[Description("Host port to publish the Stage API on.")]
[DefaultValue(9090)]
public int Port { get; set; } = 9090;
}
44 changes: 44 additions & 0 deletions Source/Cli/Commands/Run/StageContainer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Describes the Stage Docker container the run command launches and builds its invocation arguments.
/// </summary>
public static class StageContainer
{
/// <summary>
/// The Docker image name for the Stage sandbox.
/// </summary>
public const string Image = "cratis/stage";

/// <summary>
/// The port the Stage API listens on inside the container.
/// </summary>
public const int ApiPort = 9090;

/// <summary>
/// The path inside the container the folder of Screenplay files is mounted at.
/// </summary>
public const string MountPath = "/eventmodel";

/// <summary>
/// Builds the argument list for <c>docker run</c> that launches the Stage container with the given
/// folder mounted and the Stage API published on the host.
/// </summary>
/// <param name="path">The absolute path to the folder of Screenplay files to mount.</param>
/// <param name="tag">The image tag to run.</param>
/// <param name="hostPort">The host port to publish the Stage API on.</param>
/// <returns>The ordered argument list to pass to the <c>docker</c> executable.</returns>
public static IReadOnlyList<string> BuildRunArguments(string path, string tag, int hostPort) =>
[
"run",
"--rm",
"-p",
$"{hostPort}:{ApiPort}",
"-v",
$"{path}:{MountPath}",
$"{Image}:{tag}"
];
}
Loading