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/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 9215333..12f24e6 100644 --- a/src/AzLocal.Cli/Commands/StartCommand.cs +++ b/src/AzLocal.Cli/Commands/StartCommand.cs @@ -1,5 +1,88 @@ -namespace AzLocal.Cli.Commands; +using AzLocal.Core; +using System.CommandLine; +using System.Diagnostics; +using System.Runtime.InteropServices; -public class StartCommand +namespace AzLocal.Cli.Commands; + +public static class StartCommand { + public static Command Build() + { + 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.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"); + var pidFile = Path.Combine(pidDir, "host.pid"); + + if (File.Exists(pidFile) && int.TryParse(File.ReadAllText(pidFile).Trim(), out var existing)) + { + 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); + + var proc = Process.Start(BuildHostProcessInfo(port)); + + if (proc is null) + { + Console.Error.WriteLine("Failed to start host process."); + return; + } + + File.WriteAllText(pidFile, proc.Id.ToString()); + Console.WriteLine($"AzLocal started on http://localhost:{port} (pid {proc.Id})"); + }); + + return cmd; + } + + 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 da3ab9c..f7874a2 100644 --- a/src/AzLocal.Cli/Program.cs +++ b/src/AzLocal.Cli/Program.cs @@ -1,4 +1,13 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using AzLocal.Cli.Commands; +using System.CommandLine; -Console.WriteLine("Hey welcome to this package where big thing are need to happen for ust o "); +var rootCommand = new RootCommand("AzLocal — local Azure emulator CLI"); + +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";