using System.CommandLine; using System.CommandLine.Parsing; using System.Text.Json; using ScadaLink.Commons.Messages.Management; namespace ScadaLink.CLI.Commands; internal static class CommandHelpers { /// /// Resolves the management URL, credentials, and output format, then sends /// to the management API and returns the process exit code. /// /// Parsed command-line result from which option values are read. /// Option that supplies the management URL override. /// Option that supplies the output format override. /// Option that supplies the username override. /// Option that supplies the password override. /// The management command object to send. /// /// Optional per-command HTTP timeout. Defaults to 30s, matching the management API's /// own request timeout. Larger payloads (e.g. Transport bundles) should supply a /// longer value. /// /// /// Optional success handler. When supplied, the helper invokes it with the success /// body instead of running the default rendering path — /// useful when the caller needs to capture the response (e.g. write a file) rather /// than print it. The authorization-failure exit-code contract /// () is preserved on the error path either way, /// closing CLI-017's regression. /// internal static async Task ExecuteCommandAsync( ParseResult result, Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, object command, TimeSpan? timeout = null, Func? onSuccess = null) { var config = CliConfig.Load(); var format = ResolveFormat(result, formatOption, config); // Resolve management URL var url = result.GetValue(urlOption); if (string.IsNullOrWhiteSpace(url)) url = config.ManagementUrl; if (string.IsNullOrWhiteSpace(url)) { OutputFormatter.WriteError( "No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadalink/config.json.", "NO_URL"); return 1; } if (!IsValidManagementUrl(url)) { OutputFormatter.WriteError( $"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).", "INVALID_URL"); return 1; } // Resolve credentials: command-line options take precedence, then the // SCADALINK_USERNAME / SCADALINK_PASSWORD environment variables. var username = ResolveCredential(result.GetValue(usernameOption), config.Username); var password = ResolveCredential(result.GetValue(passwordOption), config.Password); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { OutputFormatter.WriteError( "Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.", "NO_CREDENTIALS"); return 1; } // Derive command name from type var commandName = ManagementCommandRegistry.GetCommandName(command.GetType()); // Send via HTTP using var client = new ManagementHttpClient(url, username, password); var response = await client.SendCommandAsync(commandName, command, timeout ?? TimeSpan.FromSeconds(30)); // Caller-supplied success handler short-circuits the default rendering — but // the error path still routes through IsAuthorizationFailure so the documented // exit-2 contract holds whether or not a custom handler is provided // (CLI-017 unification of the bundle path). if (onSuccess is not null) { if (response.JsonData is not null) return onSuccess(response.JsonData); OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR"); return IsAuthorizationFailure(response) ? 2 : 1; } return HandleResponse(response, format); } /// /// Resolves the output format using the documented precedence chain: /// an explicitly supplied --format option wins, otherwise the /// config-file / environment-variable default () /// is used, otherwise json. The --format option must not declare a /// DefaultValueFactory — that would mask whether the flag was supplied. /// /// Parsed command-line result. /// The --format option definition. /// Loaded CLI configuration providing the default format fallback. internal static string ResolveFormat(ParseResult result, Option formatOption, CliConfig config) { // GetResult returns non-null only when the option was actually present on the // command line, letting an explicit --format override the config default. if (result.GetResult(formatOption) != null) { var explicitValue = result.GetValue(formatOption); if (!string.IsNullOrWhiteSpace(explicitValue)) return explicitValue; } return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat; } /// /// Resolves a single credential: an explicit command-line value wins, otherwise the /// environment-variable fallback (from ) is used. /// /// Value supplied on the command line, or null if absent. /// Fallback value from the config file or environment variable. internal static string? ResolveCredential(string? commandLineValue, string? envValue) => string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue; /// /// Validates that a management URL is an absolute http/https URL. A malformed URL /// (missing scheme, empty, or a non-http scheme) would otherwise reach /// new Uri(...) in the constructor and throw /// an unhandled . /// /// URL string to validate. internal static bool IsValidManagementUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return false; return Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); } /// /// Writes the management response to stdout and returns the appropriate process exit code. /// /// Response received from the management API. /// Output format (json or table). internal static int HandleResponse(ManagementResponse response, string format) { if (response.JsonData != null) { // A success status with an empty/whitespace body (e.g. a 204 from a delete) // is a "command succeeded, no output" case — do not attempt to parse it. if (string.IsNullOrWhiteSpace(response.JsonData)) { Console.WriteLine("(ok)"); return 0; } if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) { WriteAsTable(response.JsonData); } else { Console.WriteLine(response.JsonData); } return 0; } var errorCode = response.ErrorCode ?? "ERROR"; var error = response.Error ?? "Unknown error"; OutputFormatter.WriteError(error, errorCode); return IsAuthorizationFailure(response) ? 2 : 1; } /// /// Determines whether an error response represents an authorization failure /// (insufficient role), which the documented exit-code table maps to exit code 2. /// An HTTP 403 status is the primary signal; the server may also signal it via an /// UNAUTHORIZED / FORBIDDEN error code on a different HTTP status, so /// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials /// — is deliberately not treated as authorization failure; it is exit 1.) /// internal static bool IsAuthorizationFailure(ManagementResponse response) { if (response.StatusCode == 403) return true; return string.Equals(response.ErrorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase) || string.Equals(response.ErrorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase); } private static void WriteAsTable(string json) { JsonDocument doc; try { doc = JsonDocument.Parse(json); } catch (JsonException) { // The server returned a success status but a non-JSON body (e.g. a proxy // HTML error page, or a plain-text message). Print it verbatim rather than // crashing — mirrors the raw-body fallback on the JSON path. Console.WriteLine(json); return; } using (doc) { var root = doc.RootElement; if (root.ValueKind == JsonValueKind.Array) { var items = root.EnumerateArray().ToList(); if (items.Count == 0) { Console.WriteLine("(no results)"); return; } // Derive the header set as the union of property names across *every* // element, in first-seen order. Using only items[0] would silently drop // columns for any later element with a different shape (CLI-016). var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList(); string[] headers; if (objectItems.Count > 0) { var seen = new List(); var known = new HashSet(StringComparer.Ordinal); foreach (var item in objectItems) foreach (var prop in item.EnumerateObject()) if (known.Add(prop.Name)) seen.Add(prop.Name); headers = seen.ToArray(); } else { headers = new[] { "Value" }; } var rows = items.Select(item => { if (item.ValueKind == JsonValueKind.Object) { return headers.Select(h => item.TryGetProperty(h, out var val) ? val.ValueKind == JsonValueKind.Null ? "" : val.ToString() : "").ToArray(); } return new[] { item.ToString() }; }); OutputFormatter.WriteTable(rows, headers); } else if (root.ValueKind == JsonValueKind.Object) { var headers = new[] { "Property", "Value" }; var rows = root.EnumerateObject().Select(p => new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() }); OutputFormatter.WriteTable(rows, headers); } else { Console.WriteLine(root.ToString()); } } } }