diff --git a/.github/PROJECT_STRUCTURE.md b/.github/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..6f626ba --- /dev/null +++ b/.github/PROJECT_STRUCTURE.md @@ -0,0 +1,121 @@ +# SRunner Project Structure + +## Overview +SRunner is a C# CLI application to run configured services and stacks, built with Terminal.GUI, .NET 10, and System.CommandLine. + +## Project Structure + +``` +SRunner/ +├── .github/ # GitHub configuration and AI instructions +│ └── PROJECT_STRUCTURE.md +├── src/ # Source code directory +│ ├── Core/ # Core business logic (non-UI) +│ │ └── ServiceRunner.cs # Service models and management logic +│ └── Cli/ # Command-line interface (entry point) +│ ├── Program.cs # Entry point with System.CommandLine +│ ├── InteractiveUI.cs # Terminal.GUI interactive interface +│ └── Cli.csproj # CLI project file +├── SRunner.slnx # Solution file +├── .gitignore # Git ignore file +└── README.md # Project documentation +``` + +## Key Technologies + +- **Framework**: .NET 10 +- **UI Library**: Terminal.Gui 1.19.0 +- **CLI Framework**: System.CommandLine 2.0.0-beta4.22272.1 (Note: Using stable beta4 version as it has better compatibility with the current .NET version) +- **Language**: C# with nullable reference types enabled + +## Architecture + +### Core Project (`src/Core/`) +- **Purpose**: Contains business logic and models independent of the user interface +- **Components**: + - `ServiceRunner.cs`: Contains both ServiceConfig data model and ServiceRunner management logic +- **Target Framework**: net10.0 +- **Type**: Class Library + +### Cli Project (`src/Cli/`) +- **Purpose**: Entry point and user interface implementation +- **Components**: + - `Program.cs`: Main entry point using System.CommandLine for argument parsing + - `InteractiveUI.cs`: Terminal.GUI-based interactive interface +- **Target Framework**: net10.0 +- **Type**: Console Application +- **Dependencies**: + - Core project reference + - Terminal.Gui package + - System.CommandLine package + +## Usage + +### Build the Project +```bash +dotnet build +``` + +### Run the CLI +```bash +# Non-interactive mode +dotnet run --project src/Cli + +# Interactive mode with Terminal.GUI +dotnet run --project src/Cli -- --interactive +# or +dotnet run --project src/Cli -- -i +``` + +## Development Guidelines + +### Adding New Features + +1. **Core Logic**: Add new business logic to `src/Core/` + - Keep UI-independent + - Follow existing patterns + - Add models and services as needed + +2. **UI Features**: Add new UI features to `src/Cli/` + - UI code goes in `InteractiveUI.cs` or new UI classes + - CLI argument handling goes in `Program.cs` + +3. **Dependencies**: + - Keep Core project minimal with no UI dependencies + - UI packages only in Cli project + +### Code Style +- Use nullable reference types +- Follow C# naming conventions +- Keep methods focused and single-purpose +- Add XML documentation comments for public APIs + +### Project References +- Cli project references Core project +- Core project has no dependencies on Cli + +## Command-Line Options + +- `--interactive` or `-i`: Launch the Terminal.GUI interactive interface +- Without flags: Shows basic help information + +## Interactive Interface Features + +The Terminal.GUI interface provides: +- Service list view +- Add new services with dialog +- Remove services with confirmation +- View service details +- Menu bar with File and Help options +- Keyboard shortcuts for all actions + +## Future Enhancements + +Potential areas for expansion: +- Actual service execution (start/stop processes) +- Service status monitoring +- Configuration file persistence (JSON/YAML) +- Logging and diagnostics +- Service dependency management +- Multi-stack support +- Environment variable management diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3b2d80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio cache/options directory +.vs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NuGet +*.nupkg +**/packages/* +!**/packages/build/ + +# Build folders +**/bin/ +**/obj/ + +# Rider +.idea/ +*.sln.iml + +# User-specific files +*.rsuser + +# Mono Auto Generated Files +mono_crash.* + +# Windows thumbnail cache +Thumbs.db + +# macOS +.DS_Store + +# VS Code +.vscode/ diff --git a/README.md b/README.md index 60c8760..aa595f3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ # SRunner CLI application to run configured services and stacks + +## Overview + +SRunner is a C# CLI application built with .NET 10, Terminal.GUI, and System.CommandLine. It provides both a command-line interface and an interactive Terminal UI for managing and running configured services and stacks. + +## Features + +- **Interactive Terminal UI**: Launch a full-featured Terminal.GUI interface with `--interactive` flag +- **Service Management**: Add, remove, and view configured services +- **Modern Architecture**: Clean separation between Core business logic and CLI interface +- **.NET 10**: Built on the latest .NET framework + +## Project Structure + +``` +SRunner/ +├── src/ +│ ├── Core/ # Business logic (non-UI) +│ │ └── ServiceRunner.cs +│ └── Cli/ # CLI and UI +│ ├── Program.cs +│ └── InteractiveUI.cs +├── .github/ +│ └── PROJECT_STRUCTURE.md # Detailed project documentation +└── SRunner.slnx +``` + +## Building + +```bash +dotnet build +``` + +## Usage + +### Non-Interactive Mode + +```bash +dotnet run --project src/Cli +``` + +This displays basic information about the CLI. + +### Interactive Mode + +```bash +dotnet run --project src/Cli -- --interactive +# or +dotnet run --project src/Cli -- -i +``` + +This launches the Terminal.GUI interactive interface where you can: +- View configured services +- Add new services +- Remove existing services +- View service details +- Navigate using keyboard shortcuts + +### Help + +```bash +dotnet run --project src/Cli -- --help +``` + +## Technologies + +- **.NET 10.0**: Latest .NET framework +- **Terminal.Gui 1.19.0**: Cross-platform Terminal UI toolkit +- **System.CommandLine 2.0.0-beta4**: Command-line parsing +- **C# 13**: With nullable reference types enabled + +## Development + +See [.github/PROJECT_STRUCTURE.md](.github/PROJECT_STRUCTURE.md) for detailed development guidelines and architecture documentation. + +## License + +See [LICENSE](LICENSE) file for details. + diff --git a/SRunner.slnx b/SRunner.slnx new file mode 100644 index 0000000..0fe02c5 --- /dev/null +++ b/SRunner.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj new file mode 100644 index 0000000..85b3ade --- /dev/null +++ b/src/Cli/Cli.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/src/Cli/InteractiveUI.cs b/src/Cli/InteractiveUI.cs new file mode 100644 index 0000000..d8f3c84 --- /dev/null +++ b/src/Cli/InteractiveUI.cs @@ -0,0 +1,300 @@ +using Terminal.Gui; +using Core; + +namespace Cli; + +/// +/// Interactive Terminal.GUI interface for SRunner +/// +public class InteractiveUI +{ + private readonly ServiceRunner _serviceRunner; + private ListView? _servicesListView; + private List _serviceNames = new(); + + public InteractiveUI() + { + _serviceRunner = new ServiceRunner(); + InitializeSampleServices(); + } + + private void InitializeSampleServices() + { + // Add some sample services for demonstration + _serviceRunner.AddService(new ServiceConfig + { + Name = "Web Server", + Command = "dotnet run", + WorkingDirectory = "/app/web", + AutoStart = true + }); + + _serviceRunner.AddService(new ServiceConfig + { + Name = "API Service", + Command = "npm start", + WorkingDirectory = "/app/api", + AutoStart = false + }); + + _serviceRunner.AddService(new ServiceConfig + { + Name = "Database", + Command = "docker-compose up", + WorkingDirectory = "/app/database", + AutoStart = true + }); + + UpdateServiceList(); + } + + private void UpdateServiceList() + { + _serviceNames = _serviceRunner.Services.Select(s => s.Name).ToList(); + } + + public void Run() + { + Application.Init(); + + try + { + var top = Application.Top; + + // Create main window + var win = new Window("SRunner - Service Manager") + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }; + + // Create menu bar + var menu = new MenuBar(new MenuBarItem[] + { + new MenuBarItem("_File", new MenuItem[] + { + new MenuItem("_Quit", "Exit SRunner", () => Application.RequestStop()) + }), + new MenuBarItem("_Help", new MenuItem[] + { + new MenuItem("_About", "About SRunner", () => ShowAbout()) + }) + }); + + top.Add(menu); + + // Create label + var label = new Label("Configured Services:") + { + X = 1, + Y = 1, + Width = Dim.Fill() - 2, + Height = 1 + }; + win.Add(label); + + // Create services list + _servicesListView = new ListView(_serviceNames) + { + X = 1, + Y = 2, + Width = Dim.Fill() - 2, + Height = Dim.Fill() - 6 + }; + win.Add(_servicesListView); + + // Create buttons + var addButton = new Button("_Add Service") + { + X = 1, + Y = Pos.Bottom(_servicesListView) + 1 + }; + addButton.Clicked += OnAddService; + win.Add(addButton); + + var removeButton = new Button("_Remove Service") + { + X = Pos.Right(addButton) + 2, + Y = Pos.Bottom(_servicesListView) + 1 + }; + removeButton.Clicked += OnRemoveService; + win.Add(removeButton); + + var detailsButton = new Button("_Details") + { + X = Pos.Right(removeButton) + 2, + Y = Pos.Bottom(_servicesListView) + 1 + }; + detailsButton.Clicked += OnShowDetails; + win.Add(detailsButton); + + var quitButton = new Button("_Quit") + { + X = Pos.Right(detailsButton) + 2, + Y = Pos.Bottom(_servicesListView) + 1 + }; + quitButton.Clicked += () => Application.RequestStop(); + win.Add(quitButton); + + top.Add(win); + + Application.Run(); + } + finally + { + Application.Shutdown(); + } + } + + private void OnAddService() + { + var dialog = new Dialog("Add Service", 60, 15); + + var nameLabel = new Label("Name:") + { + X = 1, + Y = 1 + }; + dialog.Add(nameLabel); + + var nameField = new TextField("") + { + X = Pos.Right(nameLabel) + 1, + Y = 1, + Width = Dim.Fill() - 2 + }; + dialog.Add(nameField); + + var commandLabel = new Label("Command:") + { + X = 1, + Y = 3 + }; + dialog.Add(commandLabel); + + var commandField = new TextField("") + { + X = 1, + Y = 4, + Width = Dim.Fill() - 2 + }; + dialog.Add(commandField); + + var workDirLabel = new Label("Working Directory:") + { + X = 1, + Y = 6 + }; + dialog.Add(workDirLabel); + + var workDirField = new TextField("") + { + X = 1, + Y = 7, + Width = Dim.Fill() - 2 + }; + dialog.Add(workDirField); + + var okButton = new Button("OK") + { + X = Pos.Center() - 10, + Y = Pos.Bottom(dialog) - 4, + IsDefault = true + }; + okButton.Clicked += () => + { + var name = nameField.Text?.ToString() ?? ""; + var command = commandField.Text?.ToString() ?? ""; + var workDir = workDirField.Text?.ToString() ?? ""; + + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(command)) + { + try + { + _serviceRunner.AddService(new ServiceConfig + { + Name = name, + Command = command, + WorkingDirectory = workDir, + AutoStart = false + }); + UpdateServiceList(); + if (_servicesListView != null) + { + _servicesListView.SetSource(_serviceNames); + } + Application.RequestStop(); + } + catch (InvalidOperationException ex) + { + MessageBox.ErrorQuery("Error", ex.Message, "OK"); + } + } + else + { + MessageBox.ErrorQuery("Error", "Name and Command are required!", "OK"); + } + }; + dialog.AddButton(okButton); + + var cancelButton = new Button("Cancel"); + cancelButton.Clicked += () => Application.RequestStop(); + dialog.AddButton(cancelButton); + + Application.Run(dialog); + } + + private void OnRemoveService() + { + if (_servicesListView?.SelectedItem >= 0 && _servicesListView.SelectedItem < _serviceNames.Count) + { + var serviceName = _serviceNames[_servicesListView.SelectedItem]; + var result = MessageBox.Query("Confirm", $"Remove service '{serviceName}'?", "Yes", "No"); + + if (result == 0) + { + _serviceRunner.RemoveService(serviceName); + UpdateServiceList(); + _servicesListView.SetSource(_serviceNames); + } + } + else + { + MessageBox.ErrorQuery("Error", "Please select a service to remove", "OK"); + } + } + + private void OnShowDetails() + { + if (_servicesListView?.SelectedItem >= 0 && _servicesListView.SelectedItem < _serviceNames.Count) + { + var serviceName = _serviceNames[_servicesListView.SelectedItem]; + var service = _serviceRunner.GetService(serviceName); + + if (service != null) + { + var details = $"Name: {service.Name}\n" + + $"Command: {service.Command}\n" + + $"Working Directory: {service.WorkingDirectory}\n" + + $"Auto Start: {service.AutoStart}"; + + MessageBox.Query("Service Details", details, "OK"); + } + } + else + { + MessageBox.ErrorQuery("Error", "Please select a service to view", "OK"); + } + } + + private void ShowAbout() + { + MessageBox.Query("About SRunner", + "SRunner v1.0\n\n" + + "CLI application to run configured services and stacks\n\n" + + "Built with Terminal.GUI and System.CommandLine", + "OK"); + } +} diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs new file mode 100644 index 0000000..797dddb --- /dev/null +++ b/src/Cli/Program.cs @@ -0,0 +1,43 @@ +using System.CommandLine; +using Core; + +namespace Cli; + +class Program +{ + static async Task Main(string[] args) + { + // Create the root command + var rootCommand = new RootCommand("SRunner - CLI application to run configured services and stacks"); + + // Create the --interactive option + var interactiveOption = new Option( + name: "--interactive", + description: "Launch interactive Terminal.GUI interface"); + interactiveOption.AddAlias("-i"); + + rootCommand.AddOption(interactiveOption); + + // Set the handler for the root command + rootCommand.SetHandler((interactive) => + { + if (interactive) + { + LaunchInteractiveMode(); + } + else + { + Console.WriteLine("SRunner - CLI application to run configured services and stacks"); + Console.WriteLine("Use --interactive or -i to launch the interactive interface"); + } + }, interactiveOption); + + return await rootCommand.InvokeAsync(args); + } + + static void LaunchInteractiveMode() + { + var ui = new InteractiveUI(); + ui.Run(); + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Core/Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Core/ServiceRunner.cs b/src/Core/ServiceRunner.cs new file mode 100644 index 0000000..13254ed --- /dev/null +++ b/src/Core/ServiceRunner.cs @@ -0,0 +1,67 @@ +namespace Core; + +/// +/// Represents a service configuration +/// +public class ServiceConfig +{ + private string _name = string.Empty; + private string _command = string.Empty; + + public string Name + { + get => _name; + set + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Service name cannot be empty", nameof(value)); + _name = value; + } + } + + public string Command + { + get => _command; + set + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Service command cannot be empty", nameof(value)); + _command = value; + } + } + + public string WorkingDirectory { get; set; } = string.Empty; + public bool AutoStart { get; set; } +} + +/// +/// Manages service execution +/// +public class ServiceRunner +{ + private readonly List _services = new(); + + public IReadOnlyList Services => _services.AsReadOnly(); + + public void AddService(ServiceConfig service) + { + ArgumentNullException.ThrowIfNull(service); + + if (_services.Any(s => s.Name.Equals(service.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"A service with the name '{service.Name}' already exists."); + } + + _services.Add(service); + } + + public void RemoveService(string name) + { + _services.RemoveAll(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + public ServiceConfig? GetService(string name) + { + return _services.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } +}