using System.CommandLine; using System.CommandLine.Parsing; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.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. Deploy operations defer to the server-owned /// deadline; ordinary commands default to 30 seconds. An explicit timeout /// overrides this policy (for example, bundle and Flow Debugger requests). /// /// /// 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. /// /// /// Optional transform applied to the success JSON body only when the resolved /// format is table. Lets a command render a compact table projection (e.g. /// template list dropping per-template attribute dumps) while /// leaving JSON output untouched for machine consumers. Ignored when /// is supplied. /// /// A task that resolves to the process exit code (0 = success, 1 = error, 2 = authorization failure). internal static async Task ExecuteCommandAsync( ParseResult result, Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, object command, TimeSpan? timeout = null, Func? onSuccess = null, Func? tableProjector = 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 SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/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 // SCADABRIDGE_USERNAME / SCADABRIDGE_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 SCADABRIDGE_USERNAME/SCADABRIDGE_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); // Every alias of a deploy contract shares the same policy. The server bounds // the operation; a shorter client deadline can report failure while it applies. var requestTimeout = timeout ?? (command is MgmtDeployInstanceCommand or MgmtDeployArtifactsCommand or MgmtDeploySiteCommand ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(30)); var response = await client.SendCommandAsync(commandName, command, requestTimeout); // Caller-supplied success handler short-circuits the default rendering — but // the error path still routes through IsAuthorizationFailure so the documented // exit code 2 contract holds whether or not a custom handler is provided. 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, tableProjector); } /// /// 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. /// The resolved format string (e.g. "json" or "table"). 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. /// The command-line value when non-empty; otherwise the environment fallback (may be null). 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. /// true when the URL is an absolute http or https URL; otherwise false. 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). /// /// Optional transform applied to the JSON body before table rendering only — JSON /// output is never altered. See . /// /// The process exit code (0 = success, 1 = error, 2 = authorization failure). internal static int HandleResponse(ManagementResponse response, string format, Func? tableProjector = null) { 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)) { // A table projector compacts the body for terminal display (e.g. dropping // per-template attribute dumps). JSON output stays full/untouched so // machine consumers keep the complete payload. var body = tableProjector != null ? tableProjector(response.JsonData) : response.JsonData; WriteAsTable(body); } 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.) /// /// The management response to inspect for authorization failure signals. /// true when the response signals an authorization failure (HTTP 403 or FORBIDDEN/UNAUTHORIZED code). 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. var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList(); // A "note" property that appears ONLY on single-property rows (a trailing // run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception // line) is not a real column: every other row's cell there would render blank, // and column padding would swallow the raw line's own leading indentation. Such // rows print as a raw unpadded line instead, at their position in the sequence, // and "note" is excluded from the header union so nothing renders a blank column // for it. var noteOnlyRows = objectItems .Where(i => i.EnumerateObject().Any(p => p.Name == "note")) .ToList(); var noteIsRawLine = noteOnlyRows.Count > 0 && noteOnlyRows.All(i => i.EnumerateObject().Count() == 1); 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 (noteIsRawLine && prop.Name == "note") continue; 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) { if (noteIsRawLine && item.EnumerateObject().Count() == 1 && item.TryGetProperty("note", out var noteValue)) { return OutputFormatter.TableRow.Raw( noteValue.ValueKind == JsonValueKind.Null ? "" : noteValue.ToString()); } return OutputFormatter.TableRow.Columns(headers.Select(h => item.TryGetProperty(h, out var val) ? val.ValueKind == JsonValueKind.Null ? "" : val.ToString() : "").ToArray()); } return OutputFormatter.TableRow.Columns(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()); } } } }