From 8e9dc351fca03667dfd04a82c650cf8844fa6a08 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sat, 23 May 2026 21:10:02 +0000 Subject: [PATCH 1/2] Add CLI commands and refactor StartCommand Added `System.CommandLine` package to enable command-line parsing. Refactored `StartCommand` to a static class with a `Build` method that supports a `--port` option and starts the AzLocal host process. Replaced placeholder code in `Program.cs` with a functional CLI setup, including a `RootCommand` and integration of `StartCommand` and placeholders for additional commands. --- src/AzLocal.Cli/AzLocal.Cli.csproj | 4 +++ src/AzLocal.Cli/Commands/StartCommand.cs | 37 ++++++++++++++++++++++-- src/AzLocal.Cli/Program.cs | 16 ++++++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/AzLocal.Cli/AzLocal.Cli.csproj b/src/AzLocal.Cli/AzLocal.Cli.csproj index 29a59ee..8f218ed 100644 --- a/src/AzLocal.Cli/AzLocal.Cli.csproj +++ b/src/AzLocal.Cli/AzLocal.Cli.csproj @@ -1,5 +1,9 @@  + + + + diff --git a/src/AzLocal.Cli/Commands/StartCommand.cs b/src/AzLocal.Cli/Commands/StartCommand.cs index 9215333..35b6c87 100644 --- a/src/AzLocal.Cli/Commands/StartCommand.cs +++ b/src/AzLocal.Cli/Commands/StartCommand.cs @@ -1,5 +1,36 @@ -namespace AzLocal.Cli.Commands; +using System.CommandLine; +using System.Diagnostics; -public class StartCommand +namespace AzLocal.Cli.Commands; + +public static class StartCommand { -} + public static Command Build() + { + var portOption = new Option("--port"); + + var cmd = new Command("start", "Start the AzLocal host") { portOption }; + + cmd.SetAction((ParseResult pr) => + { + var port = pr.GetRequiredValue(portOption); + // Option in scope + var pidDir = Path.Combine(Path.GetTempPath(), "azlocal"); + Directory.CreateDirectory(pidDir); + + var proc = Process.Start(new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project src/AzLocal.Host --urls http://localhost:{port}", + UseShellExecute = false, + CreateNoWindow = true + }); + + if (proc is null) { Console.Error.WriteLine("Failed to start."); return; } + + File.WriteAllText(Path.Combine(pidDir, "host.pid"), proc.Id.ToString()); + Console.WriteLine($"AzLocal started — http://localhost:{port} (pid {proc.Id})"); + }); + return cmd; + } +} \ No newline at end of file diff --git a/src/AzLocal.Cli/Program.cs b/src/AzLocal.Cli/Program.cs index da3ab9c..9ed85f1 100644 --- a/src/AzLocal.Cli/Program.cs +++ b/src/AzLocal.Cli/Program.cs @@ -1,4 +1,14 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using AzLocal.Cli.Commands; +using System.CommandLine; + + + +var rootCommand = new RootCommand("AzLocal — local Azure emulator CLI"); +rootCommand.Options.Add(StartCommand.Build()); +rootCommand.Options.Add(StopCommand); +rootCommand.Options.Add(StatusCommand); +rootCommand.Options.Add(ResetCommand); +rootCommand.Options.Add(TrustCertCommand); +rootCommand.Options.Add(WaitCommand); + -Console.WriteLine("Hey welcome to this package where big thing are need to happen for ust o "); From 3fa21e6febe24e64c963e7dad15a67507f665d14 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 24 May 2026 14:20:05 +0000 Subject: [PATCH 2/2] Refactor AzLocal CLI for modularity and robustness Refactored the AzLocal CLI to improve maintainability and usability: - Added `Build` methods for all commands (`start`, `stop`, `status`, `reset`, `trust-cert`, `wait`) to encapsulate logic. - Introduced `TrustDevCert` in `CertificateHelper` for cross-platform HTTPS certificate management. - Enhanced `StartCommand` with port validation, process checks, and support for both published binaries and development builds. - Centralized default port configuration in `EmulatorDefaults`. - Improved error handling and platform-specific logic for process and certificate management. --- src/AzLocal.Cli/CertificateHelper.cs | 36 ++++++++- src/AzLocal.Cli/Commands/ResetCommand.cs | 48 +++++++++++- src/AzLocal.Cli/Commands/StartCommand.cs | 82 ++++++++++++++++---- src/AzLocal.Cli/Commands/StatusCommand.cs | 35 ++++++++- src/AzLocal.Cli/Commands/StopCommand.cs | 51 +++++++++++- src/AzLocal.Cli/Commands/TrustCertCommand.cs | 22 +++++- src/AzLocal.Cli/Commands/WaitCommand.cs | 56 ++++++++++++- src/AzLocal.Cli/Program.cs | 17 ++-- src/AzLocal.Core/EmulatorDefaults.cs | 1 + 9 files changed, 313 insertions(+), 35 deletions(-) diff --git a/src/AzLocal.Cli/CertificateHelper.cs b/src/AzLocal.Cli/CertificateHelper.cs index 113c7be..41b5098 100644 --- a/src/AzLocal.Cli/CertificateHelper.cs +++ b/src/AzLocal.Cli/CertificateHelper.cs @@ -1,5 +1,39 @@ -namespace AzLocal.Cli; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace AzLocal.Cli; public static class CertificateHelper { + public static bool TrustDevCert() + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Run("dotnet", "dev-certs https -ep /usr/local/share/ca-certificates/azlocal-dev.crt --format PEM --no-password"); + Run("sudo", "update-ca-certificates"); + } + else + { + // Works on Windows and macOS + Run("dotnet", "dev-certs https --trust"); + } + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Certificate error: {ex.Message}"); + return false; + } + } + + private static void Run(string exe, string args) + { + var psi = new ProcessStartInfo(exe, args) { UseShellExecute = false }; + using var proc = Process.Start(psi) ?? throw new Exception($"Could not start '{exe}'."); + proc.WaitForExit(); + if (proc.ExitCode != 0) + throw new Exception($"'{exe}' exited with code {proc.ExitCode}."); + } } diff --git a/src/AzLocal.Cli/Commands/ResetCommand.cs b/src/AzLocal.Cli/Commands/ResetCommand.cs index 6375103..5a37653 100644 --- a/src/AzLocal.Cli/Commands/ResetCommand.cs +++ b/src/AzLocal.Cli/Commands/ResetCommand.cs @@ -1,5 +1,49 @@ -namespace AzLocal.Cli.Commands; +using System.CommandLine; +using System.Diagnostics; -public class ResetCommand +namespace AzLocal.Cli.Commands; + +public static class ResetCommand { + public static Command Build() + { + var cmd = new Command("reset", "Stop the AzLocal host and wipe all local state"); + + cmd.SetAction((ParseResult _) => + { + var azlocalDir = Path.Combine(Path.GetTempPath(), "azlocal"); + var pidFile = Path.Combine(azlocalDir, "host.pid"); + + // Stop any running host + if (File.Exists(pidFile)) + { + var raw = File.ReadAllText(pidFile).Trim(); + if (int.TryParse(raw, out var pid)) + { + try + { + var proc = Process.GetProcessById(pid); + proc.CloseMainWindow(); + if (!proc.WaitForExit(3000)) + proc.Kill(); + Console.WriteLine($"Stopped running host (pid {pid})."); + } + catch (ArgumentException) { /* already gone */ } + } + } + + // Wipe all state under the azlocal temp directory + if (Directory.Exists(azlocalDir)) + { + Directory.Delete(azlocalDir, recursive: true); + Console.WriteLine("State cleared."); + } + else + { + Console.WriteLine("Nothing to reset."); + } + }); + + return cmd; + } } diff --git a/src/AzLocal.Cli/Commands/StartCommand.cs b/src/AzLocal.Cli/Commands/StartCommand.cs index 35b6c87..12f24e6 100644 --- a/src/AzLocal.Cli/Commands/StartCommand.cs +++ b/src/AzLocal.Cli/Commands/StartCommand.cs @@ -1,5 +1,7 @@ -using System.CommandLine; +using AzLocal.Core; +using System.CommandLine; using System.Diagnostics; +using System.Runtime.InteropServices; namespace AzLocal.Cli.Commands; @@ -7,30 +9,80 @@ public static class StartCommand { public static Command Build() { - var portOption = new Option("--port"); + var portOption = new Option("--port") + { + Description = "Port to listen on", + DefaultValueFactory = _ => EmulatorDefaults.DefaultPort, + }; var cmd = new Command("start", "Start the AzLocal host") { portOption }; cmd.SetAction((ParseResult pr) => { - var port = pr.GetRequiredValue(portOption); - // Option in scope + var port = pr.GetValue(portOption); + + if (port is < 1 or > 65535) + { + Console.Error.WriteLine($"Invalid port: {port}. Must be 1-65535."); + return; + } + var pidDir = Path.Combine(Path.GetTempPath(), "azlocal"); - Directory.CreateDirectory(pidDir); + var pidFile = Path.Combine(pidDir, "host.pid"); - var proc = Process.Start(new ProcessStartInfo + if (File.Exists(pidFile) && int.TryParse(File.ReadAllText(pidFile).Trim(), out var existing)) { - FileName = "dotnet", - Arguments = $"run --project src/AzLocal.Host --urls http://localhost:{port}", - UseShellExecute = false, - CreateNoWindow = true - }); + try + { + Process.GetProcessById(existing); + Console.Error.WriteLine($"AzLocal is already running (pid {existing}). Run 'stop' first."); + return; + } + catch (ArgumentException) { /* stale pid file */ } + } + + Directory.CreateDirectory(pidDir); - if (proc is null) { Console.Error.WriteLine("Failed to start."); return; } + var proc = Process.Start(BuildHostProcessInfo(port)); + + if (proc is null) + { + Console.Error.WriteLine("Failed to start host process."); + return; + } - File.WriteAllText(Path.Combine(pidDir, "host.pid"), proc.Id.ToString()); - Console.WriteLine($"AzLocal started — http://localhost:{port} (pid {proc.Id})"); + File.WriteAllText(pidFile, proc.Id.ToString()); + Console.WriteLine($"AzLocal started on http://localhost:{port} (pid {proc.Id})"); }); + return cmd; } -} \ No newline at end of file + + private static ProcessStartInfo BuildHostProcessInfo(int port) + { + // When published, AzLocal.Host lives next to this binary. + // Fall back to 'dotnet run' for local development. + var hostExe = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.Combine(AppContext.BaseDirectory, "AzLocal.Host.exe") + : Path.Combine(AppContext.BaseDirectory, "AzLocal.Host"); + + if (File.Exists(hostExe)) + { + return new ProcessStartInfo + { + FileName = hostExe, + Arguments = $"--urls http://localhost:{port}", + UseShellExecute = false, + CreateNoWindow = true, + }; + } + + return new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project src/AzLocal.Host --urls http://localhost:{port}", + UseShellExecute = false, + CreateNoWindow = true, + }; + } +} diff --git a/src/AzLocal.Cli/Commands/StatusCommand.cs b/src/AzLocal.Cli/Commands/StatusCommand.cs index 5f8a15b..c981b07 100644 --- a/src/AzLocal.Cli/Commands/StatusCommand.cs +++ b/src/AzLocal.Cli/Commands/StatusCommand.cs @@ -1,5 +1,36 @@ -namespace AzLocal.Cli.Commands; +using AzLocal.Core; +using System.CommandLine; -public class StatusCommand +namespace AzLocal.Cli.Commands; + +public static class StatusCommand { + public static Command Build() + { + var portOption = new Option("--port") + { + Description = "Port to check", + DefaultValueFactory = _ => EmulatorDefaults.DefaultPort, + }; + + var cmd = new Command("status", "Check if AzLocal is running") { portOption }; + + cmd.SetAction(async (ParseResult pr, CancellationToken ct) => + { + var port = pr.GetValue(portOption); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; + try + { + var response = await http.GetAsync($"http://localhost:{port}/", ct); + Console.WriteLine($"RUNNING on port {port} (HTTP {(int)response.StatusCode})"); + } + catch + { + Console.WriteLine($"NOT running on port {port}"); + } + }); + + return cmd; + } } diff --git a/src/AzLocal.Cli/Commands/StopCommand.cs b/src/AzLocal.Cli/Commands/StopCommand.cs index acd3a10..9a18dff 100644 --- a/src/AzLocal.Cli/Commands/StopCommand.cs +++ b/src/AzLocal.Cli/Commands/StopCommand.cs @@ -1,5 +1,52 @@ -namespace AzLocal.Cli.Commands; +using System.CommandLine; +using System.Diagnostics; -public class StopCommand +namespace AzLocal.Cli.Commands; + +public static class StopCommand { + public static Command Build() + { + var cmd = new Command("stop", "Stop the AzLocal host"); + + cmd.SetAction((ParseResult _) => + { + var pidFile = Path.Combine(Path.GetTempPath(), "azlocal", "host.pid"); + + if (!File.Exists(pidFile)) + { + Console.WriteLine("No running host found."); + return; + } + + var raw = File.ReadAllText(pidFile).Trim(); + + if (!int.TryParse(raw, out var pid)) + { + Console.Error.WriteLine($"PID file is corrupt ('{raw}'). Removing it."); + File.Delete(pidFile); + return; + } + + try + { + var proc = Process.GetProcessById(pid); + proc.CloseMainWindow(); // graceful first + if (!proc.WaitForExit(3000)) + proc.Kill(); + + Console.WriteLine($"Stopped (pid {pid})"); + } + catch (ArgumentException) + { + Console.WriteLine("Process already gone."); + } + finally + { + File.Delete(pidFile); + } + }); + + return cmd; + } } diff --git a/src/AzLocal.Cli/Commands/TrustCertCommand.cs b/src/AzLocal.Cli/Commands/TrustCertCommand.cs index cb1cb0f..01f2400 100644 --- a/src/AzLocal.Cli/Commands/TrustCertCommand.cs +++ b/src/AzLocal.Cli/Commands/TrustCertCommand.cs @@ -1,5 +1,23 @@ -namespace AzLocal.Cli.Commands; +using System.CommandLine; -public class TrustCertCommand +namespace AzLocal.Cli.Commands; + +public static class TrustCertCommand { + public static Command Build() + { + var cmd = new Command("trust-cert", "Trust the AzLocal HTTPS developer certificate"); + + cmd.SetAction((ParseResult _) => + { + Console.WriteLine("Trusting developer certificate..."); + + if (CertificateHelper.TrustDevCert()) + Console.WriteLine("Certificate trusted successfully."); + else + Console.Error.WriteLine("Failed to trust certificate. Try running as administrator/sudo."); + }); + + return cmd; + } } diff --git a/src/AzLocal.Cli/Commands/WaitCommand.cs b/src/AzLocal.Cli/Commands/WaitCommand.cs index 72eda4e..72bf346 100644 --- a/src/AzLocal.Cli/Commands/WaitCommand.cs +++ b/src/AzLocal.Cli/Commands/WaitCommand.cs @@ -1,5 +1,57 @@ -namespace AzLocal.Cli.Commands; +using AzLocal.Core; +using System.CommandLine; -public class WaitCommand +namespace AzLocal.Cli.Commands; + +public static class WaitCommand { + public static Command Build() + { + var portOption = new Option("--port") + { + Description = "Port to poll", + DefaultValueFactory = _ => EmulatorDefaults.DefaultPort, + }; + + var timeoutOption = new Option("--timeout") + { + Description = "Seconds to wait before giving up", + DefaultValueFactory = _ => 30, + }; + + var cmd = new Command("wait", "Wait until AzLocal host is ready") { portOption, timeoutOption }; + + cmd.SetAction(async (ParseResult pr, CancellationToken ct) => + { + var port = pr.GetValue(portOption); + var timeoutSecs = pr.GetValue(timeoutOption); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSecs); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + + Console.Write($"Waiting for AzLocal on port {port}"); + + while (DateTime.UtcNow < deadline) + { + try + { + var response = await http.GetAsync($"http://localhost:{port}/", ct); + if (response.IsSuccessStatusCode) + { + Console.WriteLine(" ready."); + return; + } + } + catch { /* not up yet */ } + + Console.Write("."); + await Task.Delay(1000, ct); + } + + Console.WriteLine(); + Console.Error.WriteLine($"Timed out after {timeoutSecs}s — host did not respond on port {port}."); + }); + + return cmd; + } } diff --git a/src/AzLocal.Cli/Program.cs b/src/AzLocal.Cli/Program.cs index 9ed85f1..f7874a2 100644 --- a/src/AzLocal.Cli/Program.cs +++ b/src/AzLocal.Cli/Program.cs @@ -1,14 +1,13 @@ -using AzLocal.Cli.Commands; +using AzLocal.Cli.Commands; using System.CommandLine; - - var rootCommand = new RootCommand("AzLocal — local Azure emulator CLI"); -rootCommand.Options.Add(StartCommand.Build()); -rootCommand.Options.Add(StopCommand); -rootCommand.Options.Add(StatusCommand); -rootCommand.Options.Add(ResetCommand); -rootCommand.Options.Add(TrustCertCommand); -rootCommand.Options.Add(WaitCommand); +rootCommand.Add(StartCommand.Build()); +rootCommand.Add(StopCommand.Build()); +rootCommand.Add(StatusCommand.Build()); +rootCommand.Add(WaitCommand.Build()); +rootCommand.Add(ResetCommand.Build()); +rootCommand.Add(TrustCertCommand.Build()); +return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/AzLocal.Core/EmulatorDefaults.cs b/src/AzLocal.Core/EmulatorDefaults.cs index b1239ed..093bc7c 100644 --- a/src/AzLocal.Core/EmulatorDefaults.cs +++ b/src/AzLocal.Core/EmulatorDefaults.cs @@ -7,6 +7,7 @@ namespace AzLocal.Core; public static class EmulatorDefaults { public const string FakeToken = "fake-azlocal-token"; + public const int DefaultPort = 4566; public const string BaseUrl = "http://localhost:4566"; public const string ObjectId = "00000000-0000-0000-0000-000000000001"; public const string TenantId = "00000000-0000-0000-0000-000000000002";