refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,99 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI;
/// <summary>
/// Resolved CLI configuration combining config file values, environment variable overrides, and per-invocation credentials.
/// </summary>
public class CliConfig
{
/// <summary>Base URL of the ScadaBridge Management API (e.g. http://localhost:9000).</summary>
public string? ManagementUrl { get; set; }
/// <summary>Default output format for CLI commands; defaults to "json".</summary>
public string DefaultFormat { get; set; } = "json";
/// <summary>
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
/// Credentials are intentionally only sourced from environment variables (or the
/// command line) — never from the config file — so they are not persisted to disk.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
/// Provides a safer alternative to <c>--password</c>, which leaks into process
/// listings and shell history.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Loads CLI configuration by merging the config file, environment variables, and credential env vars.
/// </summary>
/// <returns>A populated <see cref="CliConfig"/> instance.</returns>
public static CliConfig Load()
{
var config = new CliConfig();
// Load from config file
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".scadabridge", "config.json");
if (File.Exists(configPath))
{
// CLI-021: a malformed (`JsonException`), unreadable
// (`UnauthorizedAccessException`), or otherwise faulted
// (`IOException`) config file must not crash the CLI before any
// command runs — even a command that supplies everything via
// --url/--username/--password/--format on the command line still
// calls Load() and would otherwise inherit the fault. Warn once on
// stderr and fall through to the env-var + command-line precedence
// chain with default settings.
try
{
var json = File.ReadAllText(configPath);
var fileConfig = JsonSerializer.Deserialize<CliConfigFile>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (fileConfig != null)
{
if (!string.IsNullOrEmpty(fileConfig.ManagementUrl))
config.ManagementUrl = fileConfig.ManagementUrl;
if (!string.IsNullOrEmpty(fileConfig.DefaultFormat))
config.DefaultFormat = fileConfig.DefaultFormat;
}
}
catch (Exception ex) when (ex is JsonException || ex is IOException || ex is UnauthorizedAccessException)
{
Console.Error.WriteLine(
$"warning: ignoring malformed or unreadable {configPath}: {ex.Message}");
}
}
// Override from environment variables
var envUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
if (!string.IsNullOrEmpty(envUrl))
config.ManagementUrl = envUrl;
var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
if (!string.IsNullOrEmpty(envFormat))
config.DefaultFormat = envFormat;
// Credentials from environment variables only (never the config file).
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
if (!string.IsNullOrEmpty(envUsername))
config.Username = envUsername;
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
config.Password = envPassword;
return config;
}
private class CliConfigFile
{
/// <summary>Management API URL from the config file.</summary>
public string? ManagementUrl { get; set; }
/// <summary>Default output format from the config file.</summary>
public string? DefaultFormat { get; set; }
}
}
@@ -0,0 +1,125 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class ApiMethodCommands
{
/// <summary>
/// Builds the <c>api-method</c> CLI command group with subcommands for managing inbound API methods.
/// </summary>
/// <param name="urlOption">Global option for the management URL.</param>
/// <param name="formatOption">Global option for the output format.</param>
/// <param name="usernameOption">Global option for the authentication username.</param>
/// <param name="passwordOption">Global option for the authentication password.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("api-method") { Description = "Manage inbound API methods" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all API methods" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListApiMethodsCommand());
});
return cmd;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
var cmd = new Command("get") { Description = "Get an API method by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetApiMethodCommand(id));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
var scriptOption = new Option<string>("--script") { Description = "Script code", Required = true };
var timeoutOption = new Option<int>("--timeout") { Description = "Timeout in seconds" };
timeoutOption.DefaultValueFactory = _ => 30;
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
var cmd = new Command("create") { Description = "Create an API method" };
cmd.Add(nameOption);
cmd.Add(scriptOption);
cmd.Add(timeoutOption);
cmd.Add(parametersOption);
cmd.Add(returnDefOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var script = result.GetValue(scriptOption)!;
var timeout = result.GetValue(timeoutOption);
var parameters = result.GetValue(parametersOption);
var returnDef = result.GetValue(returnDefOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateApiMethodCommand(name, script, timeout, parameters, returnDef));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
var scriptOption = new Option<string>("--script") { Description = "Script code", Required = true };
var timeoutOption = new Option<int>("--timeout") { Description = "Timeout in seconds" };
timeoutOption.DefaultValueFactory = _ => 30;
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
var cmd = new Command("update") { Description = "Update an API method" };
cmd.Add(idOption);
cmd.Add(scriptOption);
cmd.Add(timeoutOption);
cmd.Add(parametersOption);
cmd.Add(returnDefOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var script = result.GetValue(scriptOption)!;
var timeout = result.GetValue(timeoutOption);
var parameters = result.GetValue(parametersOption);
var returnDef = result.GetValue(returnDefOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateApiMethodCommand(id, script, timeout, parameters, returnDef));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
var cmd = new Command("delete") { Description = "Delete an API method" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiMethodCommand(id));
});
return cmd;
}
}
@@ -0,0 +1,111 @@
using System.CommandLine;
using System.CommandLine.Parsing;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Resolved Management API connection details for an <c>audit</c> subcommand, or an
/// error describing why resolution failed.
/// </summary>
public sealed class AuditConnection
{
/// <summary>
/// The management URL, or null if resolution failed.
/// </summary>
public string? Url { get; init; }
/// <summary>
/// The username for authentication, or null if resolution failed.
/// </summary>
public string? Username { get; init; }
/// <summary>
/// The password for authentication, or null if resolution failed.
/// </summary>
public string? Password { get; init; }
/// <summary>
/// Error message if resolution failed, or null.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Error code if resolution failed, or null.
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// Creates a failed connection with an error message and code.
/// </summary>
/// <param name="error">The error message.</param>
/// <param name="code">The error code.</param>
/// <returns>A failed AuditConnection.</returns>
public static AuditConnection Fail(string error, string code)
=> new() { Error = error, ErrorCode = code };
}
/// <summary>
/// Connection/format resolution shared by the <c>audit</c> subcommands. Mirrors the URL
/// and credential precedence used by <see cref="CommandHelpers"/> (command line → config
/// file / environment), but produces a raw <see cref="ManagementHttpClient"/> target
/// because the audit endpoints are plain REST resources rather than <c>POST /management</c>
/// command-envelope calls.
/// </summary>
public static class AuditCommandHelpers
{
/// <summary>
/// Resolves management API connection details from command line arguments, config file, or environment variables.
/// </summary>
/// <param name="result">The parsed command line arguments.</param>
/// <param name="urlOption">The URL option.</param>
/// <param name="usernameOption">The username option.</param>
/// <param name="passwordOption">The password option.</param>
/// <returns>The resolved connection details, or a failure result.</returns>
public static AuditConnection ResolveConnection(
ParseResult result,
Option<string> urlOption,
Option<string> usernameOption,
Option<string> passwordOption)
{
var config = CliConfig.Load();
var url = result.GetValue(urlOption);
if (string.IsNullOrWhiteSpace(url))
url = config.ManagementUrl;
if (string.IsNullOrWhiteSpace(url))
{
return AuditConnection.Fail(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
}
if (!CommandHelpers.IsValidManagementUrl(url))
{
return AuditConnection.Fail(
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
"INVALID_URL");
}
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return AuditConnection.Fail(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"NO_CREDENTIALS");
}
return new AuditConnection { Url = url, Username = username, Password = password };
}
/// <summary>
/// Resolves the output format from command line arguments, config file, or defaults to "table".
/// </summary>
/// <param name="result">The parsed command line arguments.</param>
/// <param name="formatOption">The format option.</param>
/// <returns>The resolved format string.</returns>
public static string ResolveFormat(ParseResult result, Option<string> formatOption)
=> CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
}
@@ -0,0 +1,249 @@
using System.CommandLine;
using System.CommandLine.Parsing;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>scadabridge audit</c> command group (Audit Log #23 M8). Provides read access to
/// the centralized append-only Audit Log via the Bundle B REST endpoints
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>), plus a v1 no-op
/// <c>verify-chain</c> placeholder for the deferred hash-chain tamper-evidence feature.
/// </summary>
public static class AuditCommands
{
/// <summary>
/// Builds the <c>audit</c> command group with query, export, and verify-chain sub-commands.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("audit") { Description = "Query and export the centralized audit log" };
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildQuery(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
// --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
// both repeated tokens (--channel A --channel B) and, with
// AllowMultipleArgumentsPerToken, a single token carrying several values
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution ID" };
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
pageSizeOption.DefaultValueFactory = _ => 100;
var allOption = new Option<bool>("--all") { Description = "Fetch every page, following the keyset cursor" };
var cmd = new Command("query") { Description = "Query audit log events" };
cmd.Add(sinceOption);
cmd.Add(untilOption);
cmd.Add(channelOption);
cmd.Add(kindOption);
cmd.Add(statusOption);
cmd.Add(siteOption);
cmd.Add(targetOption);
cmd.Add(actorOption);
cmd.Add(correlationIdOption);
cmd.Add(executionIdOption);
cmd.Add(parentExecutionIdOption);
cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption);
cmd.Add(allOption);
cmd.SetAction(async (ParseResult result) =>
{
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
if (connection.Error != null)
{
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
return 1;
}
var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
var formatter = AuditFormatterFactory.Create(format, Console.Error);
var args = new AuditQueryArgs
{
Since = result.GetValue(sinceOption),
Until = result.GetValue(untilOption),
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption),
ParentExecutionId = result.GetValue(parentExecutionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption),
};
var fetchAll = result.GetValue(allOption);
try
{
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
return await AuditQueryHelpers.RunQueryAsync(
client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow);
}
catch (FormatException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
});
return cmd;
}
private static Command BuildExport(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var sinceOption = new Option<string>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
var untilOption = new Option<string>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
// --channel/--kind/--status/--site are multi-valued — same shape as the
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
// AllowMultipleArgumentsPerToken, a single token carrying several values
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var cmd = new Command("export") { Description = "Export audit log events to a file" };
cmd.Add(sinceOption);
cmd.Add(untilOption);
cmd.Add(formatExportOption);
cmd.Add(outputOption);
cmd.Add(channelOption);
cmd.Add(kindOption);
cmd.Add(statusOption);
cmd.Add(siteOption);
cmd.Add(targetOption);
cmd.Add(actorOption);
cmd.SetAction(async (ParseResult result) =>
{
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
if (connection.Error != null)
{
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
return 1;
}
var args = new AuditExportArgs
{
Since = result.GetValue(sinceOption)!,
Until = result.GetValue(untilOption)!,
Format = result.GetValue(formatExportOption)!,
Output = result.GetValue(outputOption)!,
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
};
try
{
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow);
}
catch (FormatException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
});
return cmd;
}
private static Command BuildVerifyChain(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var monthOption = new Option<string>("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
cmd.Add(monthOption);
cmd.SetAction((ParseResult result) =>
{
var month = result.GetValue(monthOption)!;
if (!AuditVerifyChainHelpers.IsValidMonth(month))
{
OutputFormatter.WriteError(
$"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT");
return 1;
}
Console.WriteLine(
"Hash-chain tamper-evidence is not enabled in this release. "
+ "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap.");
return 0;
});
return cmd;
}
}
@@ -0,0 +1,203 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
/// Bundle B <c>GET /api/audit/export</c> parameters.
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
/// the <c>audit query</c> subcommand.
/// </summary>
public sealed class AuditExportArgs
{
/// <summary>
/// Start timestamp for the export time window.
/// </summary>
public string Since { get; set; } = string.Empty;
/// <summary>
/// End timestamp for the export time window.
/// </summary>
public string Until { get; set; } = string.Empty;
/// <summary>
/// Export format (e.g., 'json', 'csv', 'parquet').
/// </summary>
public string Format { get; set; } = string.Empty;
/// <summary>
/// Output file path for the exported audit log.
/// </summary>
public string Output { get; set; } = string.Empty;
/// <summary>
/// Channel filter values (repeated query parameter).
/// </summary>
public string[] Channel { get; set; } = Array.Empty<string>();
/// <summary>
/// Kind filter values (repeated query parameter).
/// </summary>
public string[] Kind { get; set; } = Array.Empty<string>();
/// <summary>
/// Status filter values (repeated query parameter).
/// </summary>
public string[] Status { get; set; } = Array.Empty<string>();
/// <summary>
/// Site identifier filter values (repeated query parameter).
/// </summary>
public string[] Site { get; set; } = Array.Empty<string>();
/// <summary>
/// Optional target system filter.
/// </summary>
public string? Target { get; set; }
/// <summary>
/// Optional actor/user filter.
/// </summary>
public string? Actor { get; set; }
}
/// <summary>
/// Helpers for the <c>audit export</c> subcommand: builds the export query string and
/// streams the HTTP response body straight to the destination file without buffering
/// the (potentially multi-megabyte) export in memory.
/// </summary>
public static class AuditExportHelpers
{
/// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
/// time window + format, plus optional filters. Time-specs are resolved via
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
/// </summary>
/// <param name="args">The export arguments containing filters and format.</param>
/// <param name="now">The current time for resolving relative time specifications.</param>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{
var parts = new List<string>();
void Add(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
Add("format", args.Format);
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
AddEach("status", args.Status);
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
return "?" + string.Join("&", parts);
}
/// <summary>
/// Executes the export: GETs <c>/api/audit/export</c> and copies the response body
/// stream directly to <see cref="AuditExportArgs.Output"/>. The body is never fully
/// buffered — <see cref="Stream.CopyToAsync(Stream)"/> streams in fixed-size chunks.
/// A <c>501 Not Implemented</c> (parquet not yet supported server-side) prints the
/// server message and returns a non-zero exit code.
/// </summary>
/// <param name="client">The management HTTP client for API communication.</param>
/// <param name="args">The export arguments containing filters and output file path.</param>
/// <param name="output">Text writer for command output messages.</param>
/// <param name="now">The current time for resolving relative time specifications.</param>
public static async Task<int> RunExportAsync(
ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now)
{
var qs = BuildQueryString(args, now);
HttpResponseMessage response;
try
{
response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None);
}
catch (HttpRequestException ex)
{
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
return 1;
}
using (response)
{
if (response.StatusCode == HttpStatusCode.NotImplemented)
{
var message = await response.Content.ReadAsStringAsync();
OutputFormatter.WriteError(
string.IsNullOrWhiteSpace(message)
? "Export format not implemented by the server."
: message,
"NOT_IMPLEMENTED");
return 1;
}
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync();
// CLI-018: honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the
// primary signal; the server may also surface UNAUTHORIZED /
// FORBIDDEN via the JSON error envelope on a non-403 status.
var errorCode = TryExtractErrorCode(message);
var isAuthFailure = (int)response.StatusCode == 403
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|| string.Equals(errorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
OutputFormatter.WriteError(
string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
errorCode ?? "ERROR");
return isAuthFailure ? 2 : 1;
}
await using var source = await response.Content.ReadAsStreamAsync();
await using var destination = new FileStream(
args.Output, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await source.CopyToAsync(destination);
}
output.WriteLine($"Exported audit log to {args.Output}");
return 0;
}
/// <summary>
/// Best-effort parse of the server's JSON error envelope (<c>{ "error": ..., "code": ... }</c>)
/// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or
/// has no <c>code</c> property — callers fall back to "ERROR" in that case.
/// </summary>
internal static string? TryExtractErrorCode(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("code", out var codeProp)
&& codeProp.ValueKind == JsonValueKind.String)
{
return codeProp.GetString();
}
}
catch (JsonException)
{
// Body is not a JSON envelope (e.g. an HTML proxy error page); no code to extract.
}
return null;
}
}
@@ -0,0 +1,53 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Renders a page of audit-log events to a writer. The <c>audit query</c> command picks
/// a formatter from the <c>--format</c> option. The default JSONL formatter is defined
/// here; the human-readable table formatter is supplied by Bundle C.
/// </summary>
public interface IAuditFormatter
{
/// <summary>Renders one page of events. Called once per fetched page.</summary>
/// <param name="events">The audit events on this page.</param>
/// <param name="output">Writer to render the formatted output to.</param>
void WritePage(IReadOnlyList<JsonElement> events, TextWriter output);
}
/// <summary>
/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events
/// are flushed as they arrive, so <c>--all</c> over many pages does not accumulate.
/// </summary>
public sealed class JsonLinesAuditFormatter : IAuditFormatter
{
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
/// <inheritdoc />
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{
foreach (var evt in events)
output.WriteLine(JsonSerializer.Serialize(evt, Compact));
}
}
/// <summary>
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value:
/// <c>table</c> renders a column-aligned text table (<see cref="TableAuditFormatter"/>),
/// any other value (including <c>json</c>) renders JSONL.
/// </summary>
public static class AuditFormatterFactory
{
/// <summary>
/// Returns an <see cref="IAuditFormatter"/> for the given format name.
/// </summary>
/// <param name="format">Format name; <c>table</c> selects the table formatter, any other value selects JSONL.</param>
/// <param name="notices">Writer for notice messages emitted during formatting.</param>
public static IAuditFormatter Create(string format, TextWriter notices)
{
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
return new TableAuditFormatter();
return new JsonLinesAuditFormatter();
}
}
@@ -0,0 +1,101 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>scadabridge audit-config</c> command group: views the configuration-change
/// audit log (the <c>IAuditService</c> trail of admin edits — distinct from the
/// centralized append-only Audit Log served by <see cref="AuditCommands"/>).
/// </summary>
/// <remarks>
/// Renamed from <c>audit-log</c> in #23 M8-T7 to avoid confusion with the new
/// <c>scadabridge audit</c> group. The old <c>audit-log</c> name is retained as a
/// deprecated alias; <see cref="DeprecatedAlias"/> still resolves the full subcommand
/// tree, and <c>Program.cs</c> prints a deprecation warning when it is used.
/// </remarks>
public static class AuditLogCommands
{
/// <summary>The deprecated alias kept for backward compatibility with the old command name.</summary>
public const string DeprecatedAlias = "audit-log";
/// <summary>The deprecation warning emitted when the old <c>audit-log</c> name is used.</summary>
public const string DeprecationWarning =
"Warning: 'audit-log' is deprecated and will be removed in a future release. "
+ "Use 'audit-config' instead.";
/// <summary>
/// Writes the <see cref="DeprecationWarning"/> to <paramref name="stderr"/> when the
/// CLI was invoked via the deprecated <c>audit-log</c> command name (i.e. the first
/// argument is <see cref="DeprecatedAlias"/>). The command itself still works — it is
/// an alias of <c>audit-config</c> — so this only adds the migration warning.
/// Factored out of <c>Program.cs</c> so it is unit-testable without spawning a process.
/// </summary>
/// <param name="args">The raw command-line arguments passed to the CLI.</param>
/// <param name="stderr">The text writer to emit the deprecation warning to.</param>
public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr)
{
if (args.Length > 0
&& string.Equals(args[0], DeprecatedAlias, StringComparison.Ordinal))
{
stderr.WriteLine(DeprecationWarning);
}
}
/// <summary>
/// Builds the <c>audit-config</c> command (with the deprecated <c>audit-log</c> alias) and its subcommands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
// Backward-compatible alias for the pre-M8 `audit-log` name. The alias keeps
// full subcommand parity automatically; the deprecation warning is emitted by
// the args[0] check in Program.cs.
command.Aliases.Add(DeprecatedAlias);
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildQuery(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var userOption = new Option<string?>("--user") { Description = "Filter by username" };
var entityTypeOption = new Option<string?>("--entity-type") { Description = "Filter by entity type" };
var actionOption = new Option<string?>("--action") { Description = "Filter by action" };
var fromOption = new Option<DateTimeOffset?>("--from") { Description = "Start date (ISO 8601)" };
var toOption = new Option<DateTimeOffset?>("--to") { Description = "End date (ISO 8601)" };
var pageOption = new Option<int>("--page") { Description = "Page number" };
pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
pageSizeOption.DefaultValueFactory = _ => 50;
var cmd = new Command("query") { Description = "Query audit log entries" };
cmd.Add(userOption);
cmd.Add(entityTypeOption);
cmd.Add(actionOption);
cmd.Add(fromOption);
cmd.Add(toOption);
cmd.Add(pageOption);
cmd.Add(pageSizeOption);
cmd.SetAction(async (ParseResult result) =>
{
var user = result.GetValue(userOption);
var entityType = result.GetValue(entityTypeOption);
var action = result.GetValue(actionOption);
var from = result.GetValue(fromOption);
var to = result.GetValue(toOption);
var page = result.GetValue(pageOption);
var pageSize = result.GetValue(pageSizeOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize));
});
return cmd;
}
}
@@ -0,0 +1,231 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set.
/// </summary>
public sealed class AuditQueryArgs
{
/// <summary>Start time spec (relative like 1h, or absolute ISO-8601).</summary>
public string? Since { get; set; }
/// <summary>End time spec (relative like 7d, or absolute ISO-8601).</summary>
public string? Until { get; set; }
/// <summary>Multi-valued channel filter.</summary>
public string[] Channel { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued audit event kind filter.</summary>
public string[] Kind { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued status filter.</summary>
public string[] Status { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued site ID filter.</summary>
public string[] Site { get; set; } = Array.Empty<string>();
/// <summary>Target system or service filter.</summary>
public string? Target { get; set; }
/// <summary>Actor (user or system) filter.</summary>
public string? Actor { get; set; }
/// <summary>Operation correlation ID filter.</summary>
public string? CorrelationId { get; set; }
/// <summary>Script execution ID filter.</summary>
public string? ExecutionId { get; set; }
/// <summary>Parent execution ID filter.</summary>
public string? ParentExecutionId { get; set; }
/// <summary>Filter for errors only (status=Failed).</summary>
public bool ErrorsOnly { get; set; }
/// <summary>Page size for pagination.</summary>
public int PageSize { get; set; } = 100;
}
/// <summary>
/// Pure helpers for the <c>audit query</c> subcommand: time-spec resolution, query-string
/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring
/// so each piece is unit-testable without standing up the command tree.
/// </summary>
public static class AuditQueryHelpers
{
// <number><unit> where unit is s/m/h/d — a relative offset back from "now".
private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
/// <summary>
/// Resolves a time-spec to an absolute <see cref="DateTimeOffset"/>. Accepts a
/// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
/// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
/// </summary>
/// <param name="spec">The time specification string.</param>
/// <param name="now">The current time used as reference for relative specs.</param>
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(spec))
throw new FormatException("Empty time value.");
var match = RelativeSpec.Match(spec.Trim());
if (match.Success)
{
var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var offset = match.Groups[2].Value switch
{
"s" => TimeSpan.FromSeconds(amount),
"m" => TimeSpan.FromMinutes(amount),
"h" => TimeSpan.FromHours(amount),
"d" => TimeSpan.FromDays(amount),
_ => throw new FormatException($"Unknown time unit in '{spec}'."),
};
return now - offset;
}
if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute))
{
return absolute;
}
throw new FormatException(
$"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp.");
}
/// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
/// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </summary>
/// <param name="args">The audit query arguments.</param>
/// <param name="now">The current time for resolving relative time specs.</param>
/// <param name="afterOccurredAtUtc">Optional keyset cursor timestamp.</param>
/// <param name="afterEventId">Optional keyset cursor event ID.</param>
public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
{
var parts = new List<string>();
void Add(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
if (!string.IsNullOrWhiteSpace(args.Since))
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(args.Until))
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
// --errors-only is a convenience shorthand for the Failed status filter. The
// server's status filter is multi-value, but --errors-only stays a single-status
// override: it pins status=Failed and supersedes any explicit --status values.
if (args.ErrorsOnly)
{
Add("status", "Failed");
}
else
{
AddEach("status", args.Status);
}
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
Add("parentExecutionId", args.ParentExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue)
Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture));
Add("afterEventId", afterEventId);
return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
}
/// <summary>
/// Executes the query: GETs <c>/api/audit/query</c>, renders each page with
/// <paramref name="formatter"/>, and — when <paramref name="fetchAll"/> is set —
/// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
/// process exit code (0 success, non-zero on HTTP/transport error).
/// </summary>
/// <param name="client">The management HTTP client.</param>
/// <param name="args">The audit query arguments.</param>
/// <param name="fetchAll">Whether to follow pagination cursors.</param>
/// <param name="formatter">The audit result formatter.</param>
/// <param name="output">The output writer for results.</param>
/// <param name="now">The current time for resolving relative time specs.</param>
public static async Task<int> RunQueryAsync(
ManagementHttpClient client,
AuditQueryArgs args,
bool fetchAll,
IAuditFormatter formatter,
TextWriter output,
DateTimeOffset now)
{
DateTimeOffset? afterOccurredAtUtc = null;
string? afterEventId = null;
while (true)
{
var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId);
var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30));
if (response.JsonData == null)
{
OutputFormatter.WriteError(
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
// CLI-018: surface the documented "authorization failure → exit 2"
// contract for the audit REST surface too, not just /management.
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
}
using var doc = JsonDocument.Parse(response.JsonData);
var root = doc.RootElement;
var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array
? evts.EnumerateArray().ToList()
: new List<JsonElement>();
formatter.WritePage(events, output);
output.Flush();
if (!fetchAll)
return 0;
if (!root.TryGetProperty("nextCursor", out var cursor)
|| cursor.ValueKind != JsonValueKind.Object)
{
return 0;
}
afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1)
&& c1.ValueKind == JsonValueKind.String
? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
: null;
afterEventId = cursor.TryGetProperty("afterEventId", out var c2)
&& c2.ValueKind == JsonValueKind.String
? c2.GetString()
: null;
// A malformed cursor (object present but missing both keys) would loop
// forever — treat it as the end of results.
if (afterOccurredAtUtc == null && afterEventId == null)
return 0;
}
}
}
@@ -0,0 +1,21 @@
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Helpers for the <c>audit verify-chain</c> subcommand. v1 is a no-op: hash-chain
/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
/// validates its <c>--month</c> argument so the surface is stable for v1.x.
/// </summary>
public static class AuditVerifyChainHelpers
{
/// <summary>
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
/// </summary>
/// <param name="month">The month string to validate in YYYY-MM format.</param>
public static bool IsValidMonth(string? month)
=> !string.IsNullOrWhiteSpace(month)
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
DateTimeStyles.None, out _);
}
@@ -0,0 +1,365 @@
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;
/// <summary>
/// Transport (#24) bundle export / preview / import. The bundle bytes travel
/// through the management endpoint as base64 inside the standard JSON envelope
/// so no transport plumbing diverges from the other commands; the CLI handles
/// file I/O at the edges.
/// </summary>
public static class BundleCommands
{
private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5);
/// <summary>Builds the <c>bundle</c> command group with export, preview, and import sub-commands.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option.</param>
/// <param name="passwordOption">Shared password option.</param>
/// <returns>The configured <see cref="Command"/> for the bundle group.</returns>
public static Command Build(
Option<string> urlOption, Option<string> formatOption,
Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("bundle")
{
Description = "Export, preview, and import Transport bundles",
};
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildPreview(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildImport(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
// ====================================================================
// bundle export
// ====================================================================
private static Command BuildExport(
Option<string> urlOption, Option<string> formatOption,
Option<string> usernameOption, Option<string> passwordOption)
{
var outputOption = new Option<string>("--output")
{
Description = "Output file path (.scadabundle)",
Required = true,
};
var passphraseOption = new Option<string?>("--passphrase")
{
Description = "Encryption passphrase. Omit to produce an unencrypted bundle.",
};
var allOption = new Option<bool>("--all")
{
Description = "Export every entity of every supported type (ignores per-type name flags).",
};
var templatesOption = NameListOption("--templates", "Comma-separated template names");
var sharedScriptsOption = NameListOption("--shared-scripts", "Comma-separated shared-script names");
var externalSystemsOption = NameListOption("--external-systems", "Comma-separated external-system names");
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names");
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
var includeDepsOption = new Option<bool>("--include-dependencies")
{
Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.",
};
var sourceEnvOption = new Option<string?>("--source-environment")
{
Description = "SourceEnvironment value stamped into the bundle manifest. Defaults to 'cli'.",
};
var cmd = new Command("export")
{
Description = "Export a bundle to a file",
};
cmd.Add(outputOption);
cmd.Add(passphraseOption);
cmd.Add(allOption);
cmd.Add(templatesOption);
cmd.Add(sharedScriptsOption);
cmd.Add(externalSystemsOption);
cmd.Add(dbConnectionsOption);
cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption);
cmd.Add(apiKeysOption);
cmd.Add(apiMethodsOption);
cmd.Add(includeDepsOption);
cmd.Add(sourceEnvOption);
cmd.SetAction(async (ParseResult result) =>
{
var output = result.GetValue(outputOption)!;
var passphrase = result.GetValue(passphraseOption);
var all = result.GetValue(allOption);
var includeDeps = result.GetValue(includeDepsOption);
var sourceEnv = result.GetValue(sourceEnvOption) ?? "cli";
var payload = new ExportBundleCommand(
All: all,
TemplateNames: result.GetValue(templatesOption),
SharedScriptNames: result.GetValue(sharedScriptsOption),
ExternalSystemNames: result.GetValue(externalSystemsOption),
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
NotificationListNames: result.GetValue(notificationListsOption),
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
ApiKeyNames: result.GetValue(apiKeysOption),
ApiMethodNames: result.GetValue(apiMethodsOption),
IncludeDependencies: includeDeps,
Passphrase: passphrase,
SourceEnvironment: sourceEnv);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
payload,
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
// CLI-020: previously the JSON envelope parse + property extraction +
// base64 decode all ran unguarded — a server-side bug that omits one of
// the two expected properties, returns a null base64 value, sends invalid
// base64, or returns a malformed JSON envelope would surface as one of
// KeyNotFoundException / InvalidOperationException / FormatException /
// JsonException, i.e. an unhandled stack trace rather than the
// documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the
// envelope parse and the streamed write in a single try/catch matching
// the graceful-degradation theme established by CLI-002 / CLI-003 / CLI-005.
string base64;
int byteCount;
try
{
using var doc = JsonDocument.Parse(jsonOk);
base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!;
byteCount = doc.RootElement.GetProperty("byteCount").GetInt32();
}
catch (Exception ex) when (ex is JsonException
or KeyNotFoundException
or InvalidOperationException)
{
OutputFormatter.WriteError(
$"Server returned a malformed bundle-export response: {ex.Message}",
"INVALID_RESPONSE");
return 1;
}
// CLI-019: stream the base64 → file write so a 100 MB bundle
// doesn't double-buffer through Convert.FromBase64String's
// ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes.
// The management envelope's body is still buffered into the
// jsonOk string (wire-format limit), but the decode + write
// are now chunked, so peak working-set drops from
// ~base64+byte[]+envelope to ~base64+small-chunk.
long written;
try
{
written = StreamBase64ToFile(base64, output);
}
catch (FormatException ex)
{
OutputFormatter.WriteError(
$"Server returned invalid base64 in the bundle response: {ex.Message}",
"INVALID_RESPONSE");
return 1;
}
Console.WriteLine($"Wrote {written:N0} bytes to {output} (server reported {byteCount:N0}).");
return 0;
});
});
return cmd;
}
// ====================================================================
// bundle preview
// ====================================================================
private static Command BuildPreview(
Option<string> urlOption, Option<string> formatOption,
Option<string> usernameOption, Option<string> passwordOption)
{
var inputOption = new Option<string>("--input")
{
Description = "Bundle file path (.scadabundle)",
Required = true,
};
var passphraseOption = new Option<string?>("--passphrase")
{
Description = "Passphrase for encrypted bundles.",
};
var cmd = new Command("preview")
{
Description = "Load a bundle and print the diff preview without applying",
};
cmd.Add(inputOption);
cmd.Add(passphraseOption);
cmd.SetAction(async (ParseResult result) =>
{
var input = result.GetValue(inputOption)!;
if (!File.Exists(input))
{
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
return 1;
}
var bytes = await File.ReadAllBytesAsync(input);
var payload = new PreviewBundleCommand(
Base64Bundle: Convert.ToBase64String(bytes),
Passphrase: result.GetValue(passphraseOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
payload,
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
Console.WriteLine(jsonOk);
return 0;
});
});
return cmd;
}
// ====================================================================
// bundle import
// ====================================================================
private static Command BuildImport(
Option<string> urlOption, Option<string> formatOption,
Option<string> usernameOption, Option<string> passwordOption)
{
var inputOption = new Option<string>("--input")
{
Description = "Bundle file path (.scadabundle)",
Required = true,
};
var passphraseOption = new Option<string?>("--passphrase")
{
Description = "Passphrase for encrypted bundles.",
};
var onConflictOption = new Option<string>("--on-conflict")
{
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
DefaultValueFactory = _ => "overwrite",
};
var cmd = new Command("import")
{
Description = "Load + apply a bundle with a single global conflict policy",
};
cmd.Add(inputOption);
cmd.Add(passphraseOption);
cmd.Add(onConflictOption);
cmd.SetAction(async (ParseResult result) =>
{
var input = result.GetValue(inputOption)!;
if (!File.Exists(input))
{
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
return 1;
}
var bytes = await File.ReadAllBytesAsync(input);
var payload = new ImportBundleCommand(
Base64Bundle: Convert.ToBase64String(bytes),
Passphrase: result.GetValue(passphraseOption),
DefaultConflictPolicy: result.GetValue(onConflictOption)!);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
payload,
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
Console.WriteLine(jsonOk);
return 0;
});
});
return cmd;
}
// ====================================================================
// Shared HTTP plumbing
// ====================================================================
//
// CLI-017: bundle commands previously routed through a private
// RunBundleCommandAsync that re-implemented URL/credential resolution and
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
// code on a non-403 status would exit 1 instead of the documented exit 2.
// The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with
// the longer BundleCommandTimeout and a per-command success handler, so the
// exit-code contract is unified across every command group.
// CLI-019: chunked base64 → file streaming. The management envelope's
// success body is a single buffered JSON string (the wire format does not
// currently support response-body streaming), so we cannot remove the
// ~base64-string + ~envelope-string allocation. What we CAN — and do —
// remove is the intermediate ~bytecount-sized byte[] that
// Convert.FromBase64String allocates plus the synchronous File.WriteAllBytes:
// we slice the base64 string into 4-byte-multiple chunks (4 base64 chars
// decode into exactly 3 bytes, so any multiple of 4 is a clean boundary)
// and decode each chunk into a small rented buffer that we copy into the
// output FileStream. The chunk size is a tradeoff — large enough that the
// per-chunk loop overhead is negligible, small enough that we never put
// anything on the LOH (1 MB is below the 85 KB LOH threshold's larger
// cousin for buffers we don't keep). Returns the total decoded byte count
// for the post-write summary line.
internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded
internal static long StreamBase64ToFile(string base64, string outputPath)
{
if (base64 is null) throw new ArgumentNullException(nameof(base64));
if (string.IsNullOrEmpty(outputPath)) throw new ArgumentException("Output path required.", nameof(outputPath));
// Skip any leading whitespace and trailing padding noise. Convert.TryFromBase64Chars
// tolerates internal whitespace, but slicing on arbitrary positions would split a
// run of base64 chars mid-quad — round the chunk to a multiple of 4 so each slice
// is independently decodable.
var chunkChars = Base64StreamChunkChars - (Base64StreamChunkChars % 4);
var totalChars = base64.Length;
var totalWritten = 0L;
using var fileStream = new FileStream(
outputPath, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: false);
// 4 base64 chars = 3 bytes, so the decoded buffer is sized accordingly.
var byteBuffer = new byte[(chunkChars / 4) * 3];
for (var offset = 0; offset < totalChars; offset += chunkChars)
{
var take = Math.Min(chunkChars, totalChars - offset);
var slice = base64.AsSpan(offset, take);
// The final slice may be shorter than chunkChars and may carry
// trailing '=' padding; TryFromBase64Chars handles that.
if (!Convert.TryFromBase64Chars(slice, byteBuffer, out var written))
{
throw new FormatException(
$"Bundle response contained invalid base64 at character offset {offset}.");
}
fileStream.Write(byteBuffer, 0, written);
totalWritten += written;
}
return totalWritten;
}
private static Option<IReadOnlyList<string>?> NameListOption(string name, string description)
{
var opt = new Option<IReadOnlyList<string>?>(name)
{
Description = description,
CustomParser = arg =>
{
var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value;
if (string.IsNullOrWhiteSpace(token)) return null;
return token
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
},
};
return opt;
}
}
@@ -0,0 +1,30 @@
using System.CommandLine;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Factory methods for the global CLI options. Centralising option construction keeps
/// validation rules (e.g. the accepted <c>--format</c> values) in one place and makes
/// them testable without standing up the whole command tree.
/// </summary>
internal static class CliOptions
{
/// <summary>
/// Creates the global <c>--format</c> option. The option deliberately has no
/// <c>DefaultValueFactory</c> — format precedence (explicit flag → config/env →
/// <c>"json"</c>) is resolved by <see cref="CommandHelpers.ResolveFormat"/>, which
/// needs to distinguish an absent flag. The accepted values are constrained so a
/// typo (e.g. <c>--format tabel</c>) is rejected with a clear parse error rather
/// than silently falling through to JSON.
/// </summary>
internal static Option<string> CreateFormatOption()
{
var formatOption = new Option<string>("--format")
{
Description = "Output format (json or table)",
Recursive = true,
};
formatOption.AcceptOnlyFromAmong("json", "table");
return formatOption;
}
}
@@ -0,0 +1,280 @@
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
{
/// <summary>
/// Resolves the management URL, credentials, and output format, then sends <paramref name="command"/>
/// to the management API and returns the process exit code.
/// </summary>
/// <param name="result">Parsed command-line result from which option values are read.</param>
/// <param name="urlOption">Option that supplies the management URL override.</param>
/// <param name="formatOption">Option that supplies the output format override.</param>
/// <param name="usernameOption">Option that supplies the username override.</param>
/// <param name="passwordOption">Option that supplies the password override.</param>
/// <param name="command">The management command object to send.</param>
/// <param name="timeout">
/// 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.
/// </param>
/// <param name="onSuccess">
/// Optional success handler. When supplied, the helper invokes it with the success
/// body instead of running the default <see cref="HandleResponse"/> 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
/// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way,
/// closing CLI-017's regression.
/// </param>
internal static async Task<int> ExecuteCommandAsync(
ParseResult result,
Option<string> urlOption,
Option<string> formatOption,
Option<string> usernameOption,
Option<string> passwordOption,
object command,
TimeSpan? timeout = null,
Func<string, int>? 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 ~/.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
// 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);
}
/// <summary>
/// Resolves the output format using the documented precedence chain:
/// an explicitly supplied <c>--format</c> option wins, otherwise the
/// config-file / environment-variable default (<see cref="CliConfig.DefaultFormat"/>)
/// is used, otherwise <c>json</c>. The <c>--format</c> option must not declare a
/// <c>DefaultValueFactory</c> — that would mask whether the flag was supplied.
/// </summary>
/// <param name="result">Parsed command-line result.</param>
/// <param name="formatOption">The <c>--format</c> option definition.</param>
/// <param name="config">Loaded CLI configuration providing the default format fallback.</param>
internal static string ResolveFormat(ParseResult result, Option<string> 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;
}
/// <summary>
/// Resolves a single credential: an explicit command-line value wins, otherwise the
/// environment-variable fallback (from <see cref="CliConfig"/>) is used.
/// </summary>
/// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param>
/// <param name="envValue">Fallback value from the config file or environment variable.</param>
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
/// <summary>
/// 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
/// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
/// an unhandled <see cref="UriFormatException"/>.
/// </summary>
/// <param name="url">URL string to validate.</param>
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);
}
/// <summary>
/// Writes the management response to stdout and returns the appropriate process exit code.
/// </summary>
/// <param name="response">Response received from the management API.</param>
/// <param name="format">Output format (<c>json</c> or <c>table</c>).</param>
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;
}
/// <summary>
/// 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
/// <c>UNAUTHORIZED</c> / <c>FORBIDDEN</c> error code on a different HTTP status, so
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
/// </summary>
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<string>();
var known = new HashSet<string>(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());
}
}
}
}
@@ -0,0 +1,132 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class DataConnectionCommands
{
/// <summary>
/// Builds the <c>data-connection</c> command group and all its subcommands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("data-connection") { Description = "Manage data connections" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
var cmd = new Command("get") { Description = "Get a data connection by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetDataConnectionCommand(id));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol", Required = true };
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
var cmd = new Command("update") { Description = "Update a data connection" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(protocolOption);
cmd.Add(configOption);
cmd.Add(backupConfigOption);
cmd.Add(failoverRetryOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption);
var backupConfig = result.GetValue(backupConfigOption);
var failoverRetryCount = result.GetValue(failoverRetryOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateDataConnectionCommand(id, name, protocol, config, backupConfig, failoverRetryCount));
});
return cmd;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
var cmd = new Command("list") { Description = "List data connections" };
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand(siteId));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
var cmd = new Command("create") { Description = "Create a new data connection" };
cmd.Add(siteIdOption);
cmd.Add(nameOption);
cmd.Add(protocolOption);
cmd.Add(configOption);
cmd.Add(backupConfigOption);
cmd.Add(failoverRetryOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption);
var backupConfig = result.GetValue(backupConfigOption);
var failoverRetryCount = result.GetValue(failoverRetryOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateDataConnectionCommand(siteId, name, protocol, config, backupConfig, failoverRetryCount));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a data connection" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteDataConnectionCommand(id));
});
return cmd;
}
}
@@ -0,0 +1,110 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// CLI commands for managing database connection definitions.
/// </summary>
public static class DbConnectionCommands
{
/// <summary>Builds the <c>db-connection</c> command with list, get, create, update, and delete sub-commands.</summary>
/// <param name="urlOption">Global URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
/// <returns>The configured <c>db-connection</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("db-connection") { Description = "Manage database connections" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all database connections" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListDatabaseConnectionsCommand());
});
return cmd;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
var cmd = new Command("get") { Description = "Get a database connection by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetDatabaseConnectionCommand(id));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", Required = true };
var cmd = new Command("create") { Description = "Create a database connection" };
cmd.Add(nameOption);
cmd.Add(connStrOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var connStr = result.GetValue(connStrOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateDatabaseConnectionDefCommand(name, connStr));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", Required = true };
var cmd = new Command("update") { Description = "Update a database connection" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(connStrOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var connStr = result.GetValue(connStrOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateDatabaseConnectionDefCommand(id, name, connStr));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a database connection" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteDatabaseConnectionDefCommand(id));
});
return cmd;
}
}
@@ -0,0 +1,290 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class DebugCommands
{
/// <summary>Builds the <c>debug</c> command with its subcommands using the given shared CLI options.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("debug") { Description = "Runtime debugging" };
command.Add(BuildSnapshot(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildStream(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildSnapshot(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("snapshot") { Description = "Get a point-in-time snapshot of instance attribute values and alarm states" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DebugSnapshotCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildStream(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("stream") { Description = "Stream live attribute values and alarm states in real-time (Ctrl+C to stop)" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var instanceId = result.GetValue(idOption);
var config = CliConfig.Load();
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
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 ~/.scadabridge/config.json.",
"NO_URL");
return 1;
}
if (!CommandHelpers.IsValidManagementUrl(url))
{
OutputFormatter.WriteError(
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
"INVALID_URL");
return 1;
}
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = CommandHelpers.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;
}
return await StreamDebugAsync(url, username, password, instanceId, format);
});
return cmd;
}
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
{
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.Headers.Add("Authorization", $"Basic {credentials}");
})
.WithAutomaticReconnect()
.Build();
// CLI-011: CancellationTokenSource owns a WaitHandle and must be disposed.
using var cts = new CancellationTokenSource();
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
var isTable = string.Equals(format, "table", StringComparison.OrdinalIgnoreCase);
// Register event handlers
connection.On<JsonElement>("OnSnapshot", snapshot =>
{
if (isTable)
{
Console.WriteLine("=== Initial Snapshot ===");
PrintSnapshotTable(snapshot);
Console.WriteLine("=== Streaming (Ctrl+C to stop) ===");
}
else
{
var obj = new { type = "snapshot", data = snapshot };
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
}
});
connection.On<JsonElement>("OnAttributeChanged", changed =>
{
if (isTable)
{
var name = changed.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
var value = changed.TryGetProperty("value", out var v) ? v.ToString() : "?";
var quality = changed.TryGetProperty("quality", out var q) ? q.GetString() : "?";
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" ATTR {name,-30} {value,-20} {quality,-10} {ts}");
}
else
{
var obj = new { type = "attributeChanged", data = changed };
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
}
});
connection.On<JsonElement>("OnAlarmChanged", changed =>
{
if (isTable)
{
var name = changed.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
var state = changed.TryGetProperty("state", out var s) ? s.ToString() : "?";
var priority = changed.TryGetProperty("priority", out var p) ? p.ToString() : "?";
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" ALARM {name,-30} {state,-20} P{priority,-9} {ts}");
}
else
{
var obj = new { type = "alarmChanged", data = changed };
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
}
});
connection.On<string>("OnStreamTerminated", reason =>
{
Console.Error.WriteLine($"Stream terminated: {reason}");
exitTcs.TrySetResult(1);
});
connection.Closed += ex =>
{
if (!cts.IsCancellationRequested)
{
Console.Error.WriteLine(ex != null
? $"Connection lost: {ex.Message}"
: "Connection closed.");
}
exitTcs.TrySetResult(cts.IsCancellationRequested ? 0 : 1);
return Task.CompletedTask;
};
connection.Reconnecting += ex =>
{
Console.Error.WriteLine($"Reconnecting... ({ex?.Message})");
return Task.CompletedTask;
};
connection.Reconnected += _ =>
{
Console.Error.WriteLine("Reconnected. Re-subscribing...");
return connection.InvokeAsync("SubscribeInstance", instanceId);
};
// Connect and subscribe
try
{
await connection.StartAsync(cts.Token);
}
catch (Exception ex)
{
// CLI-010: Ctrl+C during connect throws OperationCanceledException — that is
// a graceful user cancellation, not a connection failure.
var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested);
if (failure.IsCancellation)
{
await connection.DisposeAsync();
return failure.ExitCode;
}
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
await connection.DisposeAsync();
return failure.ExitCode;
}
try
{
await connection.InvokeAsync("SubscribeInstance", instanceId, cts.Token);
}
catch (Exception ex)
{
OutputFormatter.WriteError($"Subscribe failed: {ex.Message}", "SUBSCRIBE_FAILED");
await connection.DisposeAsync();
return 1;
}
if (isTable)
{
Console.WriteLine($"Connected to instance {instanceId}. Waiting for data...");
}
// Wait for cancellation (Ctrl+C) or stream termination
try
{
await exitTcs.Task.WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
// Ctrl+C — graceful shutdown
}
try
{
await connection.InvokeAsync("UnsubscribeInstance");
}
catch
{
// Best effort
}
await connection.DisposeAsync();
// CLI-012: resolve the exit code from a single authoritative source. A result
// set by OnStreamTerminated/Closed always wins; a brief grace period covers a
// termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0.
return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task);
}
private static void PrintSnapshotTable(JsonElement snapshot)
{
if (snapshot.TryGetProperty("attributeValues", out var attrs) && attrs.ValueKind == JsonValueKind.Array)
{
Console.WriteLine(" Attributes:");
Console.WriteLine($" {"Name",-30} {"Value",-20} {"Quality",-10} Timestamp");
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
foreach (var av in attrs.EnumerateArray())
{
var name = av.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
var value = av.TryGetProperty("value", out var v) ? v.ToString() : "?";
var quality = av.TryGetProperty("quality", out var q) ? q.GetString() : "?";
var ts = av.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" {name,-30} {value,-20} {quality,-10} {ts}");
}
}
if (snapshot.TryGetProperty("alarmStates", out var alarms) && alarms.ValueKind == JsonValueKind.Array)
{
Console.WriteLine(" Alarms:");
Console.WriteLine($" {"Name",-30} {"State",-20} {"Priority",-10} Timestamp");
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
foreach (var al in alarms.EnumerateArray())
{
var name = al.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
var state = al.TryGetProperty("state", out var s) ? s.ToString() : "?";
var priority = al.TryGetProperty("priority", out var p) ? p.ToString() : "?";
var ts = al.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
Console.WriteLine($" {name,-30} {state,-20} P{priority,-9} {ts}");
}
}
}
}
@@ -0,0 +1,57 @@
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Pure, testable helpers for the <c>debug stream</c> command. The SignalR-driven
/// <see cref="DebugCommands"/> body itself cannot be unit-tested without a live hub, so
/// the decision logic — connect-failure classification (CLI-010) and exit-code
/// resolution after stream termination (CLI-012) — is extracted here.
/// </summary>
internal static class DebugStreamHelpers
{
/// <summary>
/// The maximum time <see cref="ResolveStreamExitCodeAsync"/> waits for an in-flight
/// <c>TrySetResult</c> (from <c>OnStreamTerminated</c>/<c>Closed</c>) to land after
/// the wait was cancelled by Ctrl+C, so a termination racing with cancellation is
/// observed deterministically rather than depending on scheduling.
/// </summary>
internal static readonly TimeSpan ExitGracePeriod = TimeSpan.FromMilliseconds(250);
/// <summary>Outcome of classifying an exception thrown while connecting.</summary>
internal readonly record struct ConnectFailure(bool IsCancellation, int ExitCode);
/// <summary>
/// Classifies an exception thrown by <c>HubConnection.StartAsync</c>. A
/// cancellation exception that coincides with a user-requested cancellation
/// (Ctrl+C during connect) is a graceful shutdown — exit 0, no error printed.
/// Anything else is a genuine connection failure — exit 1.
/// </summary>
/// <param name="ex">The exception thrown by HubConnection.StartAsync.</param>
/// <param name="cancellationRequested">True when the user requested cancellation (Ctrl+C) before the exception was thrown.</param>
internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested)
{
if (cancellationRequested && ex is OperationCanceledException)
return new ConnectFailure(IsCancellation: true, ExitCode: 0);
return new ConnectFailure(IsCancellation: false, ExitCode: 1);
}
/// <summary>
/// Resolves the <c>debug stream</c> exit code from a single authoritative source —
/// the <c>exitTcs</c> task. If a result was set by <c>OnStreamTerminated</c> or the
/// <c>Closed</c> handler it is always preferred (even when Ctrl+C also fired);
/// a brief grace period covers a termination that races with cancellation. If no
/// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0.
/// </summary>
/// <param name="exitTask">The task whose result is the intended exit code, set by OnStreamTerminated or the Closed handler.</param>
internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask)
{
if (exitTask.IsCompletedSuccessfully)
return exitTask.Result;
var completed = await Task.WhenAny(exitTask, Task.Delay(ExitGracePeriod));
if (ReferenceEquals(completed, exitTask) && exitTask.IsCompletedSuccessfully)
return exitTask.Result;
return 0;
}
}
@@ -0,0 +1,81 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class DeployCommands
{
/// <summary>
/// Builds the <c>deploy</c> command group with all sub-commands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("deploy") { Description = "Deployment operations" };
command.Add(BuildInstance(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildArtifacts(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildStatus(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildInstance(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("instance") { Description = "Deploy a single instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id));
});
return cmd;
}
private static Command BuildArtifacts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int?>("--site-id") { Description = "Target site ID (all sites if omitted)" };
var cmd = new Command("artifacts") { Description = "Deploy artifacts to site(s)" };
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId));
});
return cmd;
}
private static Command BuildStatus(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
var pageOption = new Option<int>("--page") { Description = "Page number" };
pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
pageSizeOption.DefaultValueFactory = _ => 50;
var cmd = new Command("status") { Description = "Query deployment status" };
cmd.Add(instanceIdOption);
cmd.Add(statusOption);
cmd.Add(pageOption);
cmd.Add(pageSizeOption);
cmd.SetAction(async (ParseResult result) =>
{
var instanceId = result.GetValue(instanceIdOption);
var status = result.GetValue(statusOption);
var page = result.GetValue(pageOption);
var pageSize = result.GetValue(pageSizeOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new QueryDeploymentsCommand(instanceId, status, page, pageSize));
});
return cmd;
}
}
@@ -0,0 +1,238 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class ExternalSystemCommands
{
/// <summary>
/// Builds the <c>external-system</c> CLI command group with subcommands for managing external systems.
/// </summary>
/// <param name="urlOption">Global option for the management URL.</param>
/// <param name="formatOption">Global option for the output format.</param>
/// <param name="usernameOption">Global option for the authentication username.</param>
/// <param name="passwordOption">Global option for the authentication password.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("external-system") { Description = "Manage external systems" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildMethodGroup(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
var cmd = new Command("get") { Description = "Get an external system by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetExternalSystemCommand(id));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "System name", Required = true };
var endpointUrlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", Required = true };
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type", Required = true };
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
var cmd = new Command("update") { Description = "Update an external system" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(endpointUrlOption);
cmd.Add(authTypeOption);
cmd.Add(authConfigOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var endpointUrl = result.GetValue(endpointUrlOption)!;
var authType = result.GetValue(authTypeOption)!;
var authConfig = result.GetValue(authConfigOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateExternalSystemCommand(id, name, endpointUrl, authType, authConfig));
});
return cmd;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all external systems" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListExternalSystemsCommand());
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "System name", Required = true };
var endpointUrlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", Required = true };
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type (ApiKey, BasicAuth)", Required = true };
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
var cmd = new Command("create") { Description = "Create an external system" };
cmd.Add(nameOption);
cmd.Add(endpointUrlOption);
cmd.Add(authTypeOption);
cmd.Add(authConfigOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var endpointUrl = result.GetValue(endpointUrlOption)!;
var authType = result.GetValue(authTypeOption)!;
var authConfig = result.GetValue(authConfigOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateExternalSystemCommand(name, endpointUrl, authType, authConfig));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "External system ID", Required = true };
var cmd = new Command("delete") { Description = "Delete an external system" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteExternalSystemCommand(id));
});
return cmd;
}
// -- Method subcommands --
private static Command BuildMethodGroup(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("method") { Description = "Manage external system methods" };
group.Add(BuildMethodList(urlOption, formatOption, usernameOption, passwordOption));
group.Add(BuildMethodGet(urlOption, formatOption, usernameOption, passwordOption));
group.Add(BuildMethodCreate(urlOption, formatOption, usernameOption, passwordOption));
group.Add(BuildMethodUpdate(urlOption, formatOption, usernameOption, passwordOption));
group.Add(BuildMethodDelete(urlOption, formatOption, usernameOption, passwordOption));
return group;
}
private static Command BuildMethodList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
var cmd = new Command("list") { Description = "List methods for an external system" };
cmd.Add(sysIdOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
});
return cmd;
}
private static Command BuildMethodGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var cmd = new Command("get") { Description = "Get an external system method by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildMethodCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
var httpMethodOption = new Option<string>("--http-method") { Description = "HTTP method (GET, POST, PUT, DELETE)", Required = true };
var pathOption = new Option<string>("--path") { Description = "URL path (e.g. /api/Add)", Required = true };
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
var cmd = new Command("create") { Description = "Create an external system method" };
cmd.Add(sysIdOption);
cmd.Add(nameOption);
cmd.Add(httpMethodOption);
cmd.Add(pathOption);
cmd.Add(paramsOption);
cmd.Add(returnOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateExternalSystemMethodCommand(
result.GetValue(sysIdOption),
result.GetValue(nameOption)!,
result.GetValue(httpMethodOption)!,
result.GetValue(pathOption)!,
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
return cmd;
}
private static Command BuildMethodUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var nameOption = new Option<string?>("--name") { Description = "Method name" };
var httpMethodOption = new Option<string?>("--http-method") { Description = "HTTP method" };
var pathOption = new Option<string?>("--path") { Description = "URL path" };
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
var cmd = new Command("update") { Description = "Update an external system method" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(httpMethodOption);
cmd.Add(pathOption);
cmd.Add(paramsOption);
cmd.Add(returnOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateExternalSystemMethodCommand(
result.GetValue(idOption),
result.GetValue(nameOption),
result.GetValue(httpMethodOption),
result.GetValue(pathOption),
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
return cmd;
}
private static Command BuildMethodDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var cmd = new Command("delete") { Description = "Delete an external system method" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
});
return cmd;
}
}
@@ -0,0 +1,118 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class HealthCommands
{
/// <summary>
/// Builds the <c>health</c> command group with summary, site, event-log, and parked-message sub-commands.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("health") { Description = "Health monitoring" };
command.Add(BuildSummary(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSite(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildEventLog(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildParkedMessages(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildSummary(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("summary") { Description = "Get system health summary" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetHealthSummaryCommand());
});
return cmd;
}
private static Command BuildSite(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
var cmd = new Command("site") { Description = "Get health for a specific site" };
cmd.Add(identifierOption);
cmd.SetAction(async (ParseResult result) =>
{
var identifier = result.GetValue(identifierOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetSiteHealthCommand(identifier));
});
return cmd;
}
private static Command BuildEventLog(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
var eventTypeOption = new Option<string?>("--event-type") { Description = "Filter by event type" };
var severityOption = new Option<string?>("--severity") { Description = "Filter by severity" };
var keywordOption = new Option<string?>("--keyword") { Description = "Keyword search" };
var fromOption = new Option<DateTimeOffset?>("--from") { Description = "Start date (ISO 8601)" };
var toOption = new Option<DateTimeOffset?>("--to") { Description = "End date (ISO 8601)" };
var pageOption = new Option<int>("--page") { Description = "Page number" };
pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
pageSizeOption.DefaultValueFactory = _ => 50;
var instanceNameOption = new Option<string?>("--instance-name") { Description = "Filter by instance name" };
var cmd = new Command("event-log") { Description = "Query site event logs" };
cmd.Add(siteOption);
cmd.Add(eventTypeOption);
cmd.Add(severityOption);
cmd.Add(keywordOption);
cmd.Add(fromOption);
cmd.Add(toOption);
cmd.Add(pageOption);
cmd.Add(pageSizeOption);
cmd.Add(instanceNameOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new QueryEventLogsCommand(
result.GetValue(siteOption)!,
result.GetValue(eventTypeOption),
result.GetValue(severityOption),
result.GetValue(keywordOption),
result.GetValue(fromOption),
result.GetValue(toOption),
result.GetValue(pageOption),
result.GetValue(pageSizeOption),
result.GetValue(instanceNameOption)));
});
return cmd;
}
private static Command BuildParkedMessages(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteOption = new Option<string>("--site") { Description = "Site identifier", Required = true };
var pageOption = new Option<int>("--page") { Description = "Page number" };
pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
pageSizeOption.DefaultValueFactory = _ => 50;
var cmd = new Command("parked-messages") { Description = "Query parked messages at a site" };
cmd.Add(siteOption);
cmd.Add(pageOption);
cmd.Add(pageSizeOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new QueryParkedMessagesCommand(
result.GetValue(siteOption)!,
result.GetValue(pageOption),
result.GetValue(pageSizeOption)));
});
return cmd;
}
}
@@ -0,0 +1,385 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class InstanceCommands
{
/// <summary>
/// Builds the instance command and its subcommands.
/// </summary>
/// <param name="urlOption">The URL option.</param>
/// <param name="formatOption">The format option.</param>
/// <param name="usernameOption">The username option.</param>
/// <param name="passwordOption">The password option.</param>
/// <returns>The instance command with all subcommands.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("instance") { Description = "Manage instances" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildEnable(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDisable(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("get") { Description = "Get an instance by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetInstanceCommand(id));
});
return cmd;
}
private static Command BuildSetBindings(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var bindingsOption = new Option<string>("--bindings") { Description = "JSON array of [attributeName, dataConnectionId] pairs", Required = true };
var cmd = new Command("set-bindings") { Description = "Set data connection bindings for an instance" };
cmd.Add(idOption);
cmd.Add(bindingsOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var bindingsJson = result.GetValue(bindingsOption)!;
if (!TryParseBindings(bindingsJson, out var bindings, out var error))
{
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetConnectionBindingsCommand(id, bindings!));
});
return cmd;
}
/// <summary>
/// Parses the <c>--bindings</c> argument — a JSON array of
/// <c>[attributeName, dataConnectionId]</c> pairs — into a typed list.
/// Returns <c>false</c> with a descriptive <paramref name="error"/> instead of
/// throwing when the JSON is malformed, a pair has the wrong arity, or an element
/// has the wrong type.
/// </summary>
/// <param name="json">The JSON string to parse.</param>
/// <param name="bindings">The parsed bindings list, or null if parsing fails.</param>
/// <param name="error">The error message if parsing fails, or null on success.</param>
/// <returns>True if parsing succeeded; false otherwise.</returns>
internal static bool TryParseBindings(
string json,
out List<ConnectionBinding>? bindings,
out string? error)
{
bindings = null;
error = null;
try
{
var pairs = System.Text.Json.JsonSerializer
.Deserialize<List<List<System.Text.Json.JsonElement>>>(json);
if (pairs == null)
{
error = "Bindings JSON must be a non-null array of [attributeName, dataConnectionId] pairs.";
return false;
}
var result = new List<ConnectionBinding>(pairs.Count);
foreach (var pair in pairs)
{
if (pair.Count != 2)
{
error = "Each binding must be a [attributeName, dataConnectionId] pair of exactly two elements.";
return false;
}
if (pair[0].ValueKind != System.Text.Json.JsonValueKind.String)
{
error = "The first element of each binding (attributeName) must be a string.";
return false;
}
if (pair[1].ValueKind != System.Text.Json.JsonValueKind.Number
|| !pair[1].TryGetInt32(out var connectionId))
{
error = "The second element of each binding (dataConnectionId) must be an integer.";
return false;
}
result.Add(new ConnectionBinding(pair[0].GetString()!, connectionId));
}
bindings = result;
return true;
}
catch (System.Text.Json.JsonException ex)
{
error = $"Invalid bindings JSON: {ex.Message}";
return false;
}
}
/// <summary>
/// Parses the <c>--overrides</c> argument — a JSON object of
/// <c>attributeName -> value</c> pairs — into a typed dictionary. Returns
/// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing
/// when the JSON is malformed or null.
/// </summary>
/// <param name="json">The JSON string to parse.</param>
/// <param name="overrides">The parsed overrides dictionary, or null if parsing fails.</param>
/// <param name="error">The error message if parsing fails, or null on success.</param>
/// <returns>True if parsing succeeded; false otherwise.</returns>
internal static bool TryParseOverrides(
string json,
out Dictionary<string, string?>? overrides,
out string? error)
{
overrides = null;
error = null;
try
{
var parsed = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, string?>>(json);
if (parsed == null)
{
error = "Overrides JSON must be a non-null object of attribute name -> value pairs.";
return false;
}
overrides = parsed;
return true;
}
catch (System.Text.Json.JsonException ex)
{
error = $"Invalid overrides JSON: {ex.Message}";
return false;
}
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
var templateIdOption = new Option<int?>("--template-id") { Description = "Filter by template ID" };
var searchOption = new Option<string?>("--search") { Description = "Search term" };
var cmd = new Command("list") { Description = "List instances" };
cmd.Add(siteIdOption);
cmd.Add(templateIdOption);
cmd.Add(searchOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
var templateId = result.GetValue(templateIdOption);
var search = result.GetValue(searchOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListInstancesCommand(siteId, templateId, search));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Unique instance name", Required = true };
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var areaIdOption = new Option<int?>("--area-id") { Description = "Area ID" };
var cmd = new Command("create") { Description = "Create a new instance" };
cmd.Add(nameOption);
cmd.Add(templateIdOption);
cmd.Add(siteIdOption);
cmd.Add(areaIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var templateId = result.GetValue(templateIdOption);
var siteId = result.GetValue(siteIdOption);
var areaId = result.GetValue(areaIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateInstanceCommand(name, templateId, siteId, areaId));
});
return cmd;
}
private static Command BuildDeploy(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("deploy") { Description = "Deploy an instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployInstanceCommand(id));
});
return cmd;
}
private static Command BuildEnable(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("enable") { Description = "Enable an instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtEnableInstanceCommand(id));
});
return cmd;
}
private static Command BuildDisable(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("disable") { Description = "Disable an instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDisableInstanceCommand(id));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("delete") { Description = "Delete an instance" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeleteInstanceCommand(id));
});
return cmd;
}
private static Command BuildSetOverrides(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var overridesOption = new Option<string>("--overrides") { Description = "JSON object of attribute name -> value pairs, e.g. {\"Speed\": \"100\", \"Mode\": null}", Required = true };
var cmd = new Command("set-overrides") { Description = "Set attribute overrides for an instance" };
cmd.Add(idOption);
cmd.Add(overridesOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var overridesJson = result.GetValue(overridesOption)!;
if (!TryParseOverrides(overridesJson, out var overrides, out var error))
{
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceOverridesCommand(id, overrides!));
});
return cmd;
}
private static Command BuildAlarmOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" };
// set
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var setAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true };
var setConfigOption = new Option<string?>("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" };
var setPriorityOption = new Option<int?>("--priority") { Description = "Priority override (0-1000)" };
var setCmd = new Command("set") { Description = "Set (upsert) an alarm override on an instance" };
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption);
setCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceAlarmOverrideCommand(
result.GetValue(setIdOption),
result.GetValue(setAlarmOption)!,
result.GetValue(setConfigOption),
result.GetValue(setPriorityOption)));
});
group.Add(setCmd);
// delete
var delIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var delAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name", Required = true };
var delCmd = new Command("delete") { Description = "Remove an alarm override on an instance" };
delCmd.Add(delIdOption); delCmd.Add(delAlarmOption);
delCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteInstanceAlarmOverrideCommand(
result.GetValue(delIdOption),
result.GetValue(delAlarmOption)!));
});
group.Add(delCmd);
// list
var listIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var listCmd = new Command("list") { Description = "List all alarm overrides for an instance" };
listCmd.Add(listIdOption);
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListInstanceAlarmOverridesCommand(result.GetValue(listIdOption)));
});
group.Add(listCmd);
return group;
}
private static Command BuildSetArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var areaIdOption = new Option<int?>("--area-id") { Description = "Area ID (omit to clear area assignment)" };
var cmd = new Command("set-area") { Description = "Reassign an instance to a different area" };
cmd.Add(idOption);
cmd.Add(areaIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var areaId = result.GetValue(areaIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceAreaCommand(id, areaId));
});
return cmd;
}
private static Command BuildDiff(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var cmd = new Command("diff") { Description = "Show deployment diff (deployed vs current template)" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new GetDeploymentDiffCommand(id));
});
return cmd;
}
}
@@ -0,0 +1,190 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class NotificationCommands
{
/// <summary>
/// Builds the <c>notification</c> command group with sub-commands for managing notification lists and SMTP configuration.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("notification") { Description = "Manage notification lists" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
var cmd = new Command("get") { Description = "Get a notification list by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetNotificationListCommand(id));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "List name", Required = true };
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
var cmd = new Command("update") { Description = "Update a notification list" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(emailsOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var emailsRaw = result.GetValue(emailsOption)!;
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateNotificationListCommand(id, name, emails));
});
return cmd;
}
private static Command BuildSmtp(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("smtp") { Description = "Manage SMTP configuration" };
var listCmd = new Command("list") { Description = "List SMTP configurations" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmtpConfigsCommand());
});
group.Add(listCmd);
var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
updateCmd.Add(SmtpIdOption);
updateCmd.Add(SmtpServerOption);
updateCmd.Add(SmtpPortOption);
updateCmd.Add(SmtpAuthModeOption);
updateCmd.Add(SmtpFromOption);
updateCmd.Add(SmtpTlsModeOption);
updateCmd.Add(SmtpCredentialsOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildUpdateSmtpConfigCommand(result));
});
group.Add(updateCmd);
return group;
}
// SMTP update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmtpConfigCommand (used by tests).
private static readonly Option<int> SmtpIdOption =
new("--id") { Description = "SMTP config ID", Required = true };
private static readonly Option<string> SmtpServerOption =
new("--server") { Description = "SMTP server", Required = true };
private static readonly Option<int> SmtpPortOption =
new("--port") { Description = "SMTP port", Required = true };
private static readonly Option<string> SmtpAuthModeOption =
new("--auth-mode") { Description = "Auth mode", Required = true };
private static readonly Option<string> SmtpFromOption =
new("--from-address") { Description = "From email address", Required = true };
private static readonly Option<string?> SmtpTlsModeOption = CreateTlsModeOption();
private static readonly Option<string?> SmtpCredentialsOption =
new("--credentials")
{
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
"for OAuth2 (optional; preserves existing if omitted)",
};
private static Option<string?> CreateTlsModeOption()
{
var option = new Option<string?>("--tls-mode")
{
Description = "TLS mode: None, StartTLS, or SSL (optional; preserves existing if omitted)",
};
option.AcceptOnlyFromAmong("None", "StartTLS", "SSL");
return option;
}
/// <summary>
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
/// null when omitted so the server-side handler preserves the existing values.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
{
var id = result.GetValue(SmtpIdOption);
var server = result.GetValue(SmtpServerOption)!;
var port = result.GetValue(SmtpPortOption);
var authMode = result.GetValue(SmtpAuthModeOption)!;
var from = result.GetValue(SmtpFromOption)!;
var tlsMode = result.GetValue(SmtpTlsModeOption);
var credentials = result.GetValue(SmtpCredentialsOption);
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all notification lists" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListNotificationListsCommand());
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Notification list name", Required = true };
var emailsOption = new Option<string>("--emails") { Description = "Comma-separated recipient emails", Required = true };
var cmd = new Command("create") { Description = "Create a notification list" };
cmd.Add(nameOption);
cmd.Add(emailsOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var emailsRaw = result.GetValue(emailsOption)!;
var emails = emailsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateNotificationListCommand(name, emails));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Notification list ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a notification list" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteNotificationListCommand(id));
});
return cmd;
}
}
@@ -0,0 +1,179 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class SecurityCommands
{
/// <summary>
/// Builds the <c>security</c> command group with API key, role mapping, and scope rule subcommands.
/// </summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("security") { Description = "Manage security settings" };
command.Add(BuildApiKey(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRoleMapping(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildScopeRule(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildApiKey(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("api-key") { Description = "Manage API keys" };
var listCmd = new Command("list") { Description = "List all API keys" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListApiKeysCommand());
});
group.Add(listCmd);
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true };
var createCmd = new Command("create") { Description = "Create an API key" };
createCmd.Add(nameOption);
createCmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name));
});
group.Add(createCmd);
var idOption = new Option<int>("--id") { Description = "API key ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete an API key" };
deleteCmd.Add(idOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id));
});
group.Add(deleteCmd);
var updateIdOption = new Option<int>("--id") { Description = "API key ID", Required = true };
var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
updateCmd.Add(updateIdOption);
updateCmd.Add(enabledOption);
updateCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(updateIdOption);
var enabled = result.GetValue(enabledOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled));
});
group.Add(updateCmd);
return group;
}
private static Command BuildRoleMapping(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
var listCmd = new Command("list") { Description = "List all role mappings" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListRoleMappingsCommand());
});
group.Add(listCmd);
var ldapGroupOption = new Option<string>("--ldap-group") { Description = "LDAP group name", Required = true };
var roleOption = new Option<string>("--role") { Description = "Role name", Required = true };
var createCmd = new Command("create") { Description = "Create a role mapping" };
createCmd.Add(ldapGroupOption);
createCmd.Add(roleOption);
createCmd.SetAction(async (ParseResult result) =>
{
var ldapGroup = result.GetValue(ldapGroupOption)!;
var role = result.GetValue(roleOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateRoleMappingCommand(ldapGroup, role));
});
group.Add(createCmd);
var idOption = new Option<int>("--id") { Description = "Mapping ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete a role mapping" };
deleteCmd.Add(idOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteRoleMappingCommand(id));
});
group.Add(deleteCmd);
var updateIdOption = new Option<int>("--id") { Description = "Mapping ID", Required = true };
var updateLdapGroupOption = new Option<string>("--ldap-group") { Description = "LDAP group name", Required = true };
var updateRoleOption = new Option<string>("--role") { Description = "Role name", Required = true };
var updateCmd = new Command("update") { Description = "Update a role mapping" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateLdapGroupOption);
updateCmd.Add(updateRoleOption);
updateCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(updateIdOption);
var ldapGroup = result.GetValue(updateLdapGroupOption)!;
var role = result.GetValue(updateRoleOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateRoleMappingCommand(id, ldapGroup, role));
});
group.Add(updateCmd);
return group;
}
private static Command BuildScopeRule(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("scope-rule") { Description = "Manage LDAP scope rules" };
var mappingIdOption = new Option<int>("--mapping-id") { Description = "Role mapping ID", Required = true };
var listCmd = new Command("list") { Description = "List scope rules for a mapping" };
listCmd.Add(mappingIdOption);
listCmd.SetAction(async (ParseResult result) =>
{
var mappingId = result.GetValue(mappingIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListScopeRulesCommand(mappingId));
});
group.Add(listCmd);
var addMappingIdOption = new Option<int>("--mapping-id") { Description = "Role mapping ID", Required = true };
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var addCmd = new Command("add") { Description = "Add a scope rule" };
addCmd.Add(addMappingIdOption);
addCmd.Add(siteIdOption);
addCmd.SetAction(async (ParseResult result) =>
{
var mappingId = result.GetValue(addMappingIdOption);
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new AddScopeRuleCommand(mappingId, siteId));
});
group.Add(addCmd);
var deleteIdOption = new Option<int>("--id") { Description = "Scope rule ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete a scope rule" };
deleteCmd.Add(deleteIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(deleteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteScopeRuleCommand(id));
});
group.Add(deleteCmd);
return group;
}
}
@@ -0,0 +1,119 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class SharedScriptCommands
{
/// <summary>Builds the <c>shared-script</c> command group with list, get, create, update, and delete sub-commands.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option.</param>
/// <param name="passwordOption">Shared password option.</param>
/// <returns>The configured <see cref="Command"/> for the shared-script group.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("shared-script") { Description = "Manage shared scripts" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all shared scripts" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSharedScriptsCommand());
});
return cmd;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
var cmd = new Command("get") { Description = "Get a shared script by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetSharedScriptCommand(id));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
var codeOption = new Option<string>("--code") { Description = "Script code", Required = true };
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
var cmd = new Command("create") { Description = "Create a shared script" };
cmd.Add(nameOption);
cmd.Add(codeOption);
cmd.Add(parametersOption);
cmd.Add(returnDefOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var code = result.GetValue(codeOption)!;
var parameters = result.GetValue(parametersOption);
var returnDef = result.GetValue(returnDefOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSharedScriptCommand(name, code, parameters, returnDef));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
var codeOption = new Option<string>("--code") { Description = "Script code", Required = true };
var parametersOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnDefOption = new Option<string?>("--return-def") { Description = "Return type definition" };
var cmd = new Command("update") { Description = "Update a shared script" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(codeOption);
cmd.Add(parametersOption);
cmd.Add(returnDefOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var code = result.GetValue(codeOption)!;
var parameters = result.GetValue(parametersOption);
var returnDef = result.GetValue(returnDefOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateSharedScriptCommand(id, name, code, parameters, returnDef));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Shared script ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a shared script" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteSharedScriptCommand(id));
});
return cmd;
}
}
@@ -0,0 +1,212 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class SiteCommands
{
/// <summary>
/// Builds the <c>site</c> command group and all its subcommands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("site") { Description = "Manage sites" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDeployArtifacts(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildArea(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
var cmd = new Command("get") { Description = "Get a site by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetSiteCommand(id));
});
return cmd;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all sites" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSitesCommand());
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Site name", Required = true };
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
var descOption = new Option<string?>("--description") { Description = "Site description" };
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
var cmd = new Command("create") { Description = "Create a new site" };
cmd.Add(nameOption);
cmd.Add(identifierOption);
cmd.Add(descOption);
cmd.Add(nodeAOption);
cmd.Add(nodeBOption);
cmd.Add(grpcNodeAOption);
cmd.Add(grpcNodeBOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var identifier = result.GetValue(identifierOption)!;
var desc = result.GetValue(descOption);
var nodeA = result.GetValue(nodeAOption);
var nodeB = result.GetValue(nodeBOption);
var grpcNodeA = result.GetValue(grpcNodeAOption);
var grpcNodeB = result.GetValue(grpcNodeBOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Site name", Required = true };
var descOption = new Option<string?>("--description") { Description = "Site description" };
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
var cmd = new Command("update") { Description = "Update an existing site" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(descOption);
cmd.Add(nodeAOption);
cmd.Add(nodeBOption);
cmd.Add(grpcNodeAOption);
cmd.Add(grpcNodeBOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var desc = result.GetValue(descOption);
var nodeA = result.GetValue(nodeAOption);
var nodeB = result.GetValue(nodeBOption);
var grpcNodeA = result.GetValue(grpcNodeAOption);
var grpcNodeB = result.GetValue(grpcNodeBOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateSiteCommand(id, name, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Site ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a site" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteSiteCommand(id));
});
return cmd;
}
private static Command BuildArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("area") { Description = "Manage areas" };
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var listCmd = new Command("list") { Description = "List areas for a site" };
listCmd.Add(siteIdOption);
listCmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListAreasCommand(siteId));
});
group.Add(listCmd);
var createSiteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Area name", Required = true };
var parentOption = new Option<int?>("--parent-id") { Description = "Parent area ID" };
var createCmd = new Command("create") { Description = "Create an area" };
createCmd.Add(createSiteIdOption);
createCmd.Add(nameOption);
createCmd.Add(parentOption);
createCmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(createSiteIdOption);
var name = result.GetValue(nameOption)!;
var parentId = result.GetValue(parentOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateAreaCommand(siteId, name, parentId));
});
group.Add(createCmd);
var updateIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
var updateNameOption = new Option<string>("--name") { Description = "New area name", Required = true };
var updateCmd = new Command("update") { Description = "Update an area" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateNameOption);
updateCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(updateIdOption);
var name = result.GetValue(updateNameOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new UpdateAreaCommand(id, name));
});
group.Add(updateCmd);
var deleteIdOption = new Option<int>("--id") { Description = "Area ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete an area" };
deleteCmd.Add(deleteIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(deleteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteAreaCommand(id));
});
group.Add(deleteCmd);
return group;
}
private static Command BuildDeployArtifacts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int?>("--site-id") { Description = "Target site ID (all sites if omitted)" };
var cmd = new Command("deploy-artifacts") { Description = "Deploy artifacts to site(s)" };
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new MgmtDeployArtifactsCommand(siteId));
});
return cmd;
}
}
@@ -0,0 +1,97 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Human-readable table formatter for <c>audit query --format table</c> (Audit Log
/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed
/// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are
/// truncated with an ellipsis so columns stay aligned regardless of payload size.
/// </summary>
/// <remarks>
/// A header row is emitted once per page (matching the streamable, page-at-a-time
/// contract of <see cref="IAuditFormatter"/>). An empty page emits the header only,
/// so the column shape is visible even with zero results.
/// </remarks>
public sealed class TableAuditFormatter : IAuditFormatter
{
/// <summary>JSON property name (camelCase, as the server serializes it) → column header.</summary>
private static readonly (string Property, string Header, int MaxWidth)[] Columns =
{
("occurredAtUtc", "OccurredAtUtc", 24),
("channel", "Channel", 14),
("kind", "Kind", 18),
("status", "Status", 12),
("target", "Target", 32),
("actor", "Actor", 20),
("durationMs", "DurationMs", 10),
("httpStatus", "HttpStatus", 10),
};
/// <inheritdoc />
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{
// Build every cell first so column widths account for the actual data.
var rows = new List<string[]>(events.Count);
foreach (var evt in events)
{
var cells = new string[Columns.Length];
for (var i = 0; i < Columns.Length; i++)
cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth);
rows.Add(cells);
}
var widths = new int[Columns.Length];
for (var i = 0; i < Columns.Length; i++)
widths[i] = Columns[i].Header.Length;
foreach (var row in rows)
for (var i = 0; i < Columns.Length; i++)
widths[i] = Math.Max(widths[i], row[i].Length);
WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths);
foreach (var row in rows)
WriteRow(output, row, widths);
}
/// <summary>
/// Extracts a cell value for <paramref name="property"/> from an audit event.
/// A missing property or a JSON <c>null</c> renders as an empty string (never
/// the literal text "null").
/// </summary>
private static string CellValue(JsonElement evt, string property)
{
if (evt.ValueKind != JsonValueKind.Object
|| !evt.TryGetProperty(property, out var value)
|| value.ValueKind == JsonValueKind.Null)
{
return string.Empty;
}
return value.ValueKind == JsonValueKind.String
? value.GetString() ?? string.Empty
: value.ToString();
}
/// <summary>
/// Truncates <paramref name="value"/> to <paramref name="maxWidth"/> characters,
/// replacing the tail with a single-character ellipsis so the column stays aligned.
/// </summary>
private static string Truncate(string value, int maxWidth)
{
if (maxWidth <= 0 || value.Length <= maxWidth)
return value;
if (maxWidth == 1)
return "…";
return value.Substring(0, maxWidth - 1) + "…";
}
private static void WriteRow(TextWriter output, IReadOnlyList<string> cells, int[] widths)
{
for (var i = 0; i < cells.Count; i++)
{
// Last column is not padded — avoids trailing whitespace at line end.
output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2));
}
output.WriteLine();
}
}
@@ -0,0 +1,422 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
public static class TemplateCommands
{
/// <summary>Builds the <c>template</c> command with its subcommands using the given shared CLI options.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("template") { Description = "Manage templates" };
command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildValidate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAttribute(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarm(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all templates" };
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand());
});
return cmd;
}
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var cmd = new Command("get") { Description = "Get a template by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id));
});
return cmd;
}
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
var descOption = new Option<string?>("--description") { Description = "Template description" };
var parentOption = new Option<int?>("--parent-id") { Description = "Parent template ID" };
var cmd = new Command("create") { Description = "Create a new template" };
cmd.Add(nameOption);
cmd.Add(descOption);
cmd.Add(parentOption);
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var desc = result.GetValue(descOption);
var parentId = result.GetValue(parentOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateTemplateCommand(name, desc, parentId));
});
return cmd;
}
private static Command BuildUpdate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
var descOption = new Option<string?>("--description") { Description = "Template description" };
var parentOption = new Option<int?>("--parent-id") { Description = "Parent template ID" };
var cmd = new Command("update") { Description = "Update a template" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(descOption);
cmd.Add(parentOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var desc = result.GetValue(descOption);
var parentId = result.GetValue(parentOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateCommand(id, name, desc, parentId));
});
return cmd;
}
private static Command BuildValidate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var cmd = new Command("validate") { Description = "Validate a template" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ValidateTemplateCommand(id));
});
return cmd;
}
private static Command BuildDelete(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var cmd = new Command("delete") { Description = "Delete a template" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCommand(id));
});
return cmd;
}
private static Command BuildAttribute(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("attribute") { Description = "Manage template attributes" };
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
var dataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
var valueOption = new Option<string?>("--value") { Description = "Default value" };
var descOption = new Option<string?>("--description") { Description = "Description" };
var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false;
var addCmd = new Command("add") { Description = "Add an attribute to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(nameOption);
addCmd.Add(dataTypeOption);
addCmd.Add(valueOption);
addCmd.Add(descOption);
addCmd.Add(sourceOption);
addCmd.Add(lockedOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAttributeCommand(
result.GetValue(templateIdOption),
result.GetValue(nameOption)!,
result.GetValue(dataTypeOption)!,
result.GetValue(valueOption),
result.GetValue(descOption),
result.GetValue(sourceOption),
result.GetValue(lockedOption)));
});
group.Add(addCmd);
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
var updateNameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
var updateDataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
var updateValueOption = new Option<string?>("--value") { Description = "Default value" };
var updateDescOption = new Option<string?>("--description") { Description = "Description" };
var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false;
var updateCmd = new Command("update") { Description = "Update a template attribute" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateNameOption);
updateCmd.Add(updateDataTypeOption);
updateCmd.Add(updateValueOption);
updateCmd.Add(updateDescOption);
updateCmd.Add(updateSourceOption);
updateCmd.Add(updateLockedOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateAttributeCommand(
result.GetValue(updateIdOption),
result.GetValue(updateNameOption)!,
result.GetValue(updateDataTypeOption)!,
result.GetValue(updateValueOption),
result.GetValue(updateDescOption),
result.GetValue(updateSourceOption),
result.GetValue(updateLockedOption)));
});
group.Add(updateCmd);
var deleteIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete a template attribute" };
deleteCmd.Add(deleteIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteTemplateAttributeCommand(result.GetValue(deleteIdOption)));
});
group.Add(deleteCmd);
return group;
}
private static Command BuildAlarm(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("alarm") { Description = "Manage template alarms" };
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Alarm name", Required = true };
var triggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
var priorityOption = new Option<int>("--priority") { Description = "Alarm priority", Required = true };
var descOption = new Option<string?>("--description") { Description = "Description" };
var triggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false;
var addCmd = new Command("add") { Description = "Add an alarm to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(nameOption);
addCmd.Add(triggerTypeOption);
addCmd.Add(priorityOption);
addCmd.Add(descOption);
addCmd.Add(triggerConfigOption);
addCmd.Add(lockedOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAlarmCommand(
result.GetValue(templateIdOption),
result.GetValue(nameOption)!,
result.GetValue(triggerTypeOption)!,
result.GetValue(priorityOption)!,
result.GetValue(descOption),
result.GetValue(triggerConfigOption),
result.GetValue(lockedOption)));
});
group.Add(addCmd);
var updateIdOption = new Option<int>("--id") { Description = "Alarm ID", Required = true };
var updateNameOption = new Option<string>("--name") { Description = "Alarm name", Required = true };
var updateTriggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
var updatePriorityOption = new Option<int>("--priority") { Description = "Alarm priority", Required = true };
var updateDescOption = new Option<string?>("--description") { Description = "Description" };
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false;
var updateCmd = new Command("update") { Description = "Update a template alarm" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateNameOption);
updateCmd.Add(updateTriggerTypeOption);
updateCmd.Add(updatePriorityOption);
updateCmd.Add(updateDescOption);
updateCmd.Add(updateTriggerConfigOption);
updateCmd.Add(updateLockedOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateAlarmCommand(
result.GetValue(updateIdOption),
result.GetValue(updateNameOption)!,
result.GetValue(updateTriggerTypeOption)!,
result.GetValue(updatePriorityOption)!,
result.GetValue(updateDescOption),
result.GetValue(updateTriggerConfigOption),
result.GetValue(updateLockedOption)));
});
group.Add(updateCmd);
var deleteIdOption = new Option<int>("--id") { Description = "Alarm ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete a template alarm" };
deleteCmd.Add(deleteIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteTemplateAlarmCommand(result.GetValue(deleteIdOption)));
});
group.Add(deleteCmd);
return group;
}
private static Command BuildScript(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("script") { Description = "Manage template scripts" };
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Script name", Required = true };
var codeOption = new Option<string>("--code") { Description = "Script code", Required = true };
var triggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
var triggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false;
var paramsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var addCmd = new Command("add") { Description = "Add a script to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(nameOption);
addCmd.Add(codeOption);
addCmd.Add(triggerTypeOption);
addCmd.Add(triggerConfigOption);
addCmd.Add(lockedOption);
addCmd.Add(paramsOption);
addCmd.Add(returnOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateScriptCommand(
result.GetValue(templateIdOption),
result.GetValue(nameOption)!,
result.GetValue(codeOption)!,
result.GetValue(triggerTypeOption)!,
result.GetValue(triggerConfigOption),
result.GetValue(lockedOption),
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
group.Add(addCmd);
var updateIdOption = new Option<int>("--id") { Description = "Script ID", Required = true };
var updateNameOption = new Option<string>("--name") { Description = "Script name", Required = true };
var updateCodeOption = new Option<string>("--code") { Description = "Script code", Required = true };
var updateTriggerTypeOption = new Option<string>("--trigger-type") { Description = "Trigger type", Required = true };
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false;
var updateParamsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var updateCmd = new Command("update") { Description = "Update a template script" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateNameOption);
updateCmd.Add(updateCodeOption);
updateCmd.Add(updateTriggerTypeOption);
updateCmd.Add(updateTriggerConfigOption);
updateCmd.Add(updateLockedOption);
updateCmd.Add(updateParamsOption);
updateCmd.Add(updateReturnOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateScriptCommand(
result.GetValue(updateIdOption),
result.GetValue(updateNameOption)!,
result.GetValue(updateCodeOption)!,
result.GetValue(updateTriggerTypeOption)!,
result.GetValue(updateTriggerConfigOption),
result.GetValue(updateLockedOption),
result.GetValue(updateParamsOption),
result.GetValue(updateReturnOption)));
});
group.Add(updateCmd);
var deleteIdOption = new Option<int>("--id") { Description = "Script ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete a template script" };
deleteCmd.Add(deleteIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteTemplateScriptCommand(result.GetValue(deleteIdOption)));
});
group.Add(deleteCmd);
return group;
}
private static Command BuildComposition(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("composition") { Description = "Manage template compositions" };
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var instanceNameOption = new Option<string>("--instance-name") { Description = "Composed instance name", Required = true };
var composedTemplateIdOption = new Option<int>("--composed-template-id") { Description = "Composed template ID", Required = true };
var addCmd = new Command("add") { Description = "Add a composition to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(instanceNameOption);
addCmd.Add(composedTemplateIdOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateCompositionCommand(
result.GetValue(templateIdOption),
result.GetValue(instanceNameOption)!,
result.GetValue(composedTemplateIdOption)));
});
group.Add(addCmd);
var deleteIdOption = new Option<int>("--id") { Description = "Composition ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete a template composition" };
deleteCmd.Add(deleteIdOption);
deleteCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteTemplateCompositionCommand(result.GetValue(deleteIdOption)));
});
group.Add(deleteCmd);
return group;
}
}
@@ -0,0 +1,164 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI;
public class ManagementHttpClient : IDisposable
{
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="ManagementHttpClient"/> class.
/// </summary>
/// <param name="baseUrl">The base URL for the management API.</param>
/// <param name="username">The username for HTTP Basic authentication.</param>
/// <param name="password">The password for HTTP Basic authentication.</param>
public ManagementHttpClient(string baseUrl, string username, string password)
: this(new HttpClient(), baseUrl, username, password)
{
}
/// <summary>
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
/// be exercised without a live server.
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests.</param>
/// <param name="baseUrl">The base URL for the management API.</param>
/// <param name="username">The username for HTTP Basic authentication.</param>
/// <param name="password">The password for HTTP Basic authentication.</param>
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
{
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
/// <summary>
/// Sends a management command to the management API.
/// </summary>
/// <param name="commandName">The command name to execute.</param>
/// <param name="payload">The command payload.</param>
/// <param name="timeout">The request timeout.</param>
/// <returns>A management response containing status and data.</returns>
public async Task<ManagementResponse> SendCommandAsync(string commandName, object payload, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
var body = JsonSerializer.Serialize(new { command = commandName, payload },
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var content = new StringContent(body, Encoding.UTF8, "application/json");
HttpResponseMessage httpResponse;
try
{
httpResponse = await _httpClient.PostAsync("management", content, cts.Token);
}
catch (TaskCanceledException)
{
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
}
catch (HttpRequestException ex)
{
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
}
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
if (httpResponse.IsSuccessStatusCode)
{
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
}
// Parse error response
string? error = null;
string? code = null;
try
{
using var doc = JsonDocument.Parse(responseBody);
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
}
catch
{
error = responseBody;
}
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
}
/// <summary>
/// Issues a plain HTTP <c>GET</c> against a REST endpoint (e.g. the audit
/// <c>/api/audit/query</c> endpoint introduced by Audit Log #23 M8) and returns the
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
/// </summary>
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
/// <param name="timeout">The request timeout.</param>
/// <returns>A management response containing status and data.</returns>
public async Task<ManagementResponse> SendGetAsync(string relativePath, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
HttpResponseMessage httpResponse;
try
{
httpResponse = await _httpClient.GetAsync(relativePath, cts.Token);
}
catch (TaskCanceledException)
{
return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT");
}
catch (HttpRequestException ex)
{
return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED");
}
var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token);
if (httpResponse.IsSuccessStatusCode)
{
return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null);
}
string? error = null;
string? code = null;
try
{
using var doc = JsonDocument.Parse(responseBody);
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
}
catch
{
error = responseBody;
}
return new ManagementResponse((int)httpResponse.StatusCode, null, error, code);
}
/// <summary>
/// Issues a plain HTTP <c>GET</c> and returns the raw <see cref="HttpResponseMessage"/>
/// so the caller can stream the response body without buffering it in memory — used
/// by <c>audit export</c>, where the response can be many megabytes. The caller owns
/// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/>
/// option ensures the body is not pre-buffered.
/// </summary>
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The raw HTTP response message for streaming.</returns>
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
/// <summary>
/// Disposes the underlying HTTP client.
/// </summary>
public void Dispose() => _httpClient.Dispose();
}
public record ManagementResponse(int StatusCode, string? JsonData, string? Error, string? ErrorCode);
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.CLI;
public static class OutputFormatter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>Serializes <paramref name="data"/> to indented JSON and writes it to standard output.</summary>
/// <param name="data">The object to serialize; <c>null</c> is serialized as JSON <c>null</c>.</param>
public static void WriteJson(object? data)
{
Console.WriteLine(JsonSerializer.Serialize(data, JsonOptions));
}
/// <summary>Writes a JSON error envelope with the given message and code to standard error.</summary>
/// <param name="message">Human-readable error description.</param>
/// <param name="code">Machine-readable error code.</param>
public static void WriteError(string message, string code)
{
Console.Error.WriteLine(JsonSerializer.Serialize(new { error = message, code }, JsonOptions));
}
/// <summary>Writes a plain-text padded table to standard output with the given column headers and data rows.</summary>
/// <param name="rows">Data rows; each inner array corresponds to a column in the same order as <paramref name="headers"/>.</param>
/// <param name="headers">Column header labels.</param>
public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
{
var allRows = new List<string[]> { headers };
allRows.AddRange(rows);
var widths = new int[headers.Length];
foreach (var row in allRows)
for (int i = 0; i < Math.Min(row.Length, widths.Length); i++)
widths[i] = Math.Max(widths[i], (row[i] ?? "").Length);
foreach (var row in allRows)
{
for (int i = 0; i < headers.Length; i++)
Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2));
Console.WriteLine();
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
var rootCommand = new RootCommand("ScadaBridge CLI — manage the ScadaBridge SCADA system");
var urlOption = new Option<string>("--url") { Description = "Management API URL", Recursive = true };
var usernameOption = new Option<string>("--username") { Description = "LDAP username", Recursive = true };
var passwordOption = new Option<string>("--password") { Description = "LDAP password", Recursive = true };
// No DefaultValueFactory: format precedence (explicit --format -> config/env -> "json")
// is resolved by CommandHelpers.ResolveFormat, which needs to distinguish an absent flag.
// CliOptions.CreateFormatOption also constrains the accepted values (json/table).
var formatOption = CliOptions.CreateFormatOption();
rootCommand.Add(urlOption);
rootCommand.Add(usernameOption);
rootCommand.Add(passwordOption);
rootCommand.Add(formatOption);
// Register command groups
rootCommand.Add(TemplateCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(InstanceCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SiteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DeployCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DataConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
});
// Deprecation notice for the pre-M8 `audit-log` command name. The command itself
// still works (it is an alias of `audit-config`), but using the old name emits a
// warning to stderr so scripts can be migrated.
AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error);
var parseResult = CommandLineParser.Parse(rootCommand, args);
return await parseResult.InvokeAsync();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>scadabridge</AssemblyName>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.CLI.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
</ItemGroup>
</Project>