Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs
T
Joseph Doherty e2f433e849 feat(cli): read secret-bearing option values from a file or stdin (#137)
Every CLI option that can carry a secret gets a `--<name>-file <path|->`
sibling ('-' reads stdin), so the value stays out of the process list and
shell history: --password-file (global), --primary-config-file /
--backup-config-file (data-connection create|update), --auth-config-file
(external-system create|update), --connection-string-file (db-connection
create|update), --credentials-file (notification smtp create|update) and
--auth-token-file (notification sms create|update). Follows the existing
--config-file convention; `@path` is avoided because System.CommandLine
expands @-tokens as response files.

The value is read at parse time; a missing/unreadable/empty source, or '-'
on a terminal stdin, is a parse error naming option + path only, never the
content. Inline and file forms are mutually exclusive, at most one option
may read stdin, and one trailing line break is stripped. Inline forms keep
working.

docker/seed-sites.sh and docker-env2/seed-sites.sh now write the MxGateway
config (windev gateway key) to a mode-600 temp file removed by an EXIT trap
and pass --primary-config-file; seed-flow-outbound-demo.sh pipes its rig
connection string via --connection-string-file -. CLI README (new "Secrets
from a file or stdin" section + per-command tables), Component-CLI.md and
both docker READMEs updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 00:46:17 -05:00

551 lines
30 KiB
C#

using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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>
/// <returns>The configured <c>notification</c> command with all sub-commands registered.</returns>
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(BuildRename(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildRecipient(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSmtp(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSms(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Option<int> RequiredId(string name, string description)
{
var option = new Option<int>(name) { Description = description, Required = true };
option.Validators.Add(result => { if (result.GetValueOrDefault<int>() <= 0) result.AddError($"{name} must be positive."); });
return option;
}
private static Command BuildRename(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("rename") { Description = "Rename a list, preserving all recipients and their IDs" };
var id = RequiredId("--id", "Notification list ID");
var name = new Option<string>("--name") { Required = true, Description = "New list name" };
cmd.Add(id); cmd.Add(name);
cmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new RenameNotificationListCommand(result.GetValue(id), result.GetValue(name)!)));
return cmd;
}
private static Command BuildRecipient(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("recipient") { Description = "Manage individual named recipients without replacing the list" };
var add = new Command("add") { Description = "Add a named recipient (address must match the list delivery channel)" };
var listId = RequiredId("--list-id", "Notification list ID");
var name = new Option<string>("--name") { Required = true, Description = "Recipient name" };
var email = new Option<string?>("--email") { Description = "Email address" };
var phone = new Option<string?>("--phone") { Description = "Phone number in E.164 format" };
add.Add(listId); add.Add(name); add.Add(email); add.Add(phone);
add.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(email)) && string.IsNullOrWhiteSpace(result.GetValue(phone)))
result.AddError("Provide --email and/or --phone for the recipient.");
});
add.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new AddNotificationRecipientCommand(result.GetValue(listId), result.GetValue(name)!, result.GetValue(email), result.GetValue(phone))));
var delete = new Command("delete") { Description = "Delete one recipient by its recipient ID" };
var id = RequiredId("--id", "Recipient ID");
delete.Add(id);
delete.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteNotificationRecipientCommand(result.GetValue(id))));
group.Add(add); group.Add(delete);
return group;
}
// ------------------------------------------------------------------------
// Notification list create/update options (static so the parsed values can
// be read back both from the SetAction and from the testable Build* helpers).
// ------------------------------------------------------------------------
private static readonly Option<string> ListCreateNameOption =
new("--name") { Description = "Notification list name", Required = true };
private static readonly Option<string?> ListCreateEmailsOption =
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
private static readonly Option<string?> ListCreatePhonesOption =
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
private static readonly Option<string> ListCreateTypeOption = CreateListTypeOption();
private static readonly Option<int> ListUpdateIdOption =
new("--id") { Description = "Notification list ID", Required = true };
private static readonly Option<string> ListUpdateNameOption =
new("--name") { Description = "List name", Required = true };
private static readonly Option<string?> ListUpdateEmailsOption =
new("--emails") { Description = "Comma-separated recipient emails (required for --type email; rejected for --type sms)" };
private static readonly Option<string?> ListUpdatePhonesOption =
new("--phones") { Description = "Comma-separated recipient phone numbers in E.164 (required for --type sms; rejected for --type email)" };
private static readonly Option<string> ListUpdateTypeOption = CreateListTypeOption();
private static Option<string> CreateListTypeOption()
{
var option = new Option<string>("--type")
{
Description = "Delivery channel: email or sms (case-insensitive; default email)",
DefaultValueFactory = _ => "email",
};
return option;
}
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 cmd = new Command("update") { Description = "Update a notification list" };
cmd.Add(ListUpdateIdOption);
cmd.Add(ListUpdateNameOption);
cmd.Add(ListUpdateTypeOption);
cmd.Add(ListUpdateEmailsOption);
cmd.Add(ListUpdatePhonesOption);
cmd.SetAction(async (ParseResult result) =>
{
UpdateNotificationListCommand command;
try
{
command = BuildUpdateNotificationListCommand(result);
}
catch (ArgumentException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
});
return cmd;
}
/// <summary>
/// Builds the <see cref="UpdateNotificationListCommand"/> from a parsed
/// <c>notification list update</c> invocation, applying the channel-aware
/// recipient validation. Throws <see cref="ArgumentException"/> when the
/// channel and the supplied recipient flags are inconsistent.
/// </summary>
/// <param name="result">The parsed command-line result.</param>
/// <returns>A validated <see cref="UpdateNotificationListCommand"/>.</returns>
internal static UpdateNotificationListCommand BuildUpdateNotificationListCommand(ParseResult result)
{
var id = result.GetValue(ListUpdateIdOption);
var name = result.GetValue(ListUpdateNameOption)!;
var (type, emails, phones) = ResolveListChannel(
result.GetValue(ListUpdateTypeOption)!,
result.GetValue(ListUpdateEmailsOption),
result.GetValue(ListUpdatePhonesOption));
return new UpdateNotificationListCommand(id, name, emails, type, phones);
}
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 createCmd = new Command("create") { Description = "Create SMTP configuration" };
foreach (var option in new Option[] { SmtpServerOption, SmtpPortOption, SmtpAuthModeOption, SmtpFromOption, SmtpTlsModeOption,
SmtpCredentialsOption, SmtpOAuth2AuthorityOption, SmtpOAuth2ScopeOption, SmtpTransportOption }) createCmd.Add(option);
ValueSourceOptions.AddFileOptions(createCmd, (SmtpCredentialsOption, SmtpCredentialsFileOption, false));
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmtpConfigCommand(result.GetValue(SmtpServerOption)!, result.GetValue(SmtpPortOption), result.GetValue(SmtpAuthModeOption)!,
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption),
ValueSourceOptions.GetValue(result, SmtpCredentialsOption, SmtpCredentialsFileOption),
result.GetValue(SmtpOAuth2AuthorityOption), result.GetValue(SmtpOAuth2ScopeOption), result.GetValue(SmtpTransportOption))));
group.Add(createCmd);
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.Add(SmtpOAuth2AuthorityOption);
updateCmd.Add(SmtpOAuth2ScopeOption);
updateCmd.Add(SmtpTransportOption);
ValueSourceOptions.AddFileOptions(updateCmd, (SmtpCredentialsOption, SmtpCredentialsFileOption, false));
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)",
};
// Declared after SmtpCredentialsOption: static initialisers run in textual order.
private static readonly Option<string?> SmtpCredentialsFileOption =
ValueSourceOptions.CreateFileOption(SmtpCredentialsOption, "the SMTP credentials");
private static readonly Option<string?> SmtpOAuth2AuthorityOption =
new("--oauth2-authority")
{
Description = "OAuth2 token-endpoint URL (optional; a '{tenant}' placeholder is " +
"substituted from the credential; preserves existing / M365 default if omitted)",
};
private static readonly Option<string?> SmtpOAuth2ScopeOption =
new("--oauth2-scope")
{
Description = "OAuth2 scope requested from the token endpoint " +
"(optional; preserves existing / M365 default if omitted)",
};
private static readonly Option<string?> SmtpTransportOption = CreateTransportOption();
private static Option<string?> CreateTransportOption()
{
var option = new Option<string?>("--transport")
{
Description = "Email transport: Smtp or Ews (optional; preserves existing if omitted). " +
"Under Ews, --server is the full EWS URL (https://…/ews/exchange.asmx), " +
"--auth-mode must be basic, and --port/--tls-mode are unused.",
};
option.AcceptOnlyFromAmong("Smtp", "Ews");
return option;
}
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> /
/// <c>--oauth2-authority</c> / <c>--oauth2-scope</c> / <c>--transport</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>
/// <returns>An <see cref="UpdateSmtpConfigCommand"/> populated from the parsed result.</returns>
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 = ValueSourceOptions.GetValue(result, SmtpCredentialsOption, SmtpCredentialsFileOption);
var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption);
var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption);
var transport = result.GetValue(SmtpTransportOption);
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials, oauth2Authority, oauth2Scope, transport);
}
private static Command BuildSms(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("sms") { Description = "Manage SMS (Twilio) configuration" };
var listCmd = new Command("list") { Description = "List SMS configurations (Auth Token shown as a presence flag only)" };
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListSmsConfigsCommand());
});
group.Add(listCmd);
var createCmd = new Command("create") { Description = "Create SMS configuration (Auth Token required)" };
foreach (var option in new Option[] { SmsAccountSidOption, SmsFromNumberOption, SmsMessagingServiceSidOption, SmsApiBaseUrlOption,
SmsAuthTokenOption, SmsTimeoutOption, SmsMaxRetriesOption, SmsRetryDelayOption }) createCmd.Add(option);
ValueSourceOptions.AddFileOptions(createCmd, (SmsAuthTokenOption, SmsAuthTokenFileOption, false));
createCmd.Validators.Add(result =>
{
// A file-supplied token that failed to read already carries its own parse error.
if (result.GetResult(SmsAuthTokenFileOption) is null && string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption)))
result.AddError("--auth-token (or --auth-token-file) is required when creating SMS configuration.");
if (string.IsNullOrWhiteSpace(result.GetValue(SmsFromNumberOption)) && string.IsNullOrWhiteSpace(result.GetValue(SmsMessagingServiceSidOption)))
result.AddError("Provide --from-number and/or --messaging-service-sid.");
});
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmsConfigCommand(result.GetValue(SmsAccountSidOption)!, result.GetValue(SmsFromNumberOption), result.GetValue(SmsMessagingServiceSidOption),
result.GetValue(SmsApiBaseUrlOption), ValueSourceOptions.GetValue(result, SmsAuthTokenOption, SmsAuthTokenFileOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result))));
group.Add(createCmd);
var updateCmd = new Command("update") { Description = "Update SMS configuration" };
updateCmd.Add(SmsIdOption);
updateCmd.Add(SmsAccountSidOption);
updateCmd.Add(SmsFromNumberOption);
updateCmd.Add(SmsMessagingServiceSidOption);
updateCmd.Add(SmsApiBaseUrlOption);
updateCmd.Add(SmsAuthTokenOption);
updateCmd.Add(SmsTimeoutOption);
updateCmd.Add(SmsMaxRetriesOption);
updateCmd.Add(SmsRetryDelayOption);
ValueSourceOptions.AddFileOptions(updateCmd, (SmsAuthTokenOption, SmsAuthTokenFileOption, false));
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
BuildUpdateSmsConfigCommand(result));
});
group.Add(updateCmd);
return group;
}
private static readonly Option<int?> SmsTimeoutOption = SmsPolicyOption("--connection-timeout-seconds", "Connection timeout in seconds", 1);
private static readonly Option<int?> SmsMaxRetriesOption = SmsPolicyOption("--max-retries", "Retry count (zero uses the outbox fallback)", 0);
private static readonly Option<int?> SmsRetryDelayOption = SmsPolicyOption("--retry-delay-seconds", "Retry delay in seconds (zero uses the outbox fallback)", 0);
private static Option<int?> SmsPolicyOption(string name, string description, int minimum)
{
var option = new Option<int?>(name) { Description = description + "; omission preserves updates or uses create defaults" };
option.Validators.Add(result =>
{
if (result.GetValueOrDefault<int?>() is { } value && value < minimum)
result.AddError($"{name} must be at least {minimum}.");
});
return option;
}
private static TimeSpan? SmsRetryDelay(ParseResult result) =>
result.GetValue(SmsRetryDelayOption) is { } seconds ? TimeSpan.FromSeconds(seconds) : null;
// SMS update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmsConfigCommand (used by tests).
private static readonly Option<int> SmsIdOption =
new("--id") { Description = "SMS config ID", Required = true };
private static readonly Option<string> SmsAccountSidOption =
new("--account-sid") { Description = "Twilio Account SID", Required = true };
private static readonly Option<string?> SmsFromNumberOption =
new("--from-number")
{
Description = "Sender phone number (E.164). Provide this and/or --messaging-service-sid.",
};
private static readonly Option<string?> SmsMessagingServiceSidOption =
new("--messaging-service-sid")
{
Description = "Twilio Messaging Service SID (optional; OMITTING IT CLEARS the stored value)",
};
private static readonly Option<string?> SmsApiBaseUrlOption =
new("--api-base-url")
{
Description = "API base URL override (optional; preserves existing if omitted)",
};
private static readonly Option<string?> SmsAuthTokenOption =
new("--auth-token")
{
Description = "Twilio Auth Token (optional; PRESERVES the stored token if omitted; never printed back)",
};
// Declared after SmsAuthTokenOption: static initialisers run in textual order.
private static readonly Option<string?> SmsAuthTokenFileOption =
ValueSourceOptions.CreateFileOption(SmsAuthTokenOption, "the Twilio Auth Token");
/// <summary>
/// Builds the <see cref="UpdateSmsConfigCommand"/> from a parsed <c>sms update</c>
/// invocation. Note the asymmetric preserve-vs-clear semantics enforced server-side:
/// omitting <c>--auth-token</c> / <c>--api-base-url</c> maps to null so the handler
/// PRESERVES the existing values, whereas omitting <c>--messaging-service-sid</c> also
/// maps to null but the handler OVERWRITES (clears) the stored value.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>sms update</c> invocation.</param>
/// <returns>An <see cref="UpdateSmsConfigCommand"/> populated from the parsed result.</returns>
internal static UpdateSmsConfigCommand BuildUpdateSmsConfigCommand(ParseResult result)
{
var id = result.GetValue(SmsIdOption);
var accountSid = result.GetValue(SmsAccountSidOption)!;
var fromNumber = result.GetValue(SmsFromNumberOption);
var messagingServiceSid = result.GetValue(SmsMessagingServiceSidOption);
var apiBaseUrl = result.GetValue(SmsApiBaseUrlOption);
var authToken = ValueSourceOptions.GetValue(result, SmsAuthTokenOption, SmsAuthTokenFileOption);
// Whole-replace update: the sender identity must be re-supplied. A Twilio config
// needs a From number and/or a Messaging Service SID, so reject the case where the
// update would leave neither (mirrors the UI + delivery-adapter validation).
if (string.IsNullOrWhiteSpace(fromNumber) && string.IsNullOrWhiteSpace(messagingServiceSid))
{
throw new ArgumentException(
"Provide --from-number and/or --messaging-service-sid (at least one sender identity is required).");
}
return new UpdateSmsConfigCommand(id, accountSid, fromNumber, messagingServiceSid, apiBaseUrl, authToken,
result.GetValue(SmsTimeoutOption), result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result));
}
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 cmd = new Command("create") { Description = "Create a notification list" };
cmd.Add(ListCreateNameOption);
cmd.Add(ListCreateTypeOption);
cmd.Add(ListCreateEmailsOption);
cmd.Add(ListCreatePhonesOption);
cmd.SetAction(async (ParseResult result) =>
{
CreateNotificationListCommand command;
try
{
command = BuildCreateNotificationListCommand(result);
}
catch (ArgumentException ex)
{
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, command);
});
return cmd;
}
/// <summary>
/// Builds the <see cref="CreateNotificationListCommand"/> from a parsed
/// <c>notification list create</c> invocation, applying the channel-aware
/// recipient validation. Throws <see cref="ArgumentException"/> when the
/// channel and the supplied recipient flags are inconsistent.
/// </summary>
/// <param name="result">The parsed command-line result.</param>
/// <returns>A validated <see cref="CreateNotificationListCommand"/>.</returns>
internal static CreateNotificationListCommand BuildCreateNotificationListCommand(ParseResult result)
{
var name = result.GetValue(ListCreateNameOption)!;
var (type, emails, phones) = ResolveListChannel(
result.GetValue(ListCreateTypeOption)!,
result.GetValue(ListCreateEmailsOption),
result.GetValue(ListCreatePhonesOption));
return new CreateNotificationListCommand(name, emails, type, phones);
}
/// <summary>
/// Parses the <c>--type</c> value (case-insensitive) and validates that the supplied
/// recipient flags match the channel: <c>email</c> requires <c>--emails</c> and rejects
/// <c>--phones</c>; <c>sms</c> requires <c>--phones</c> and rejects <c>--emails</c>.
/// </summary>
/// <param name="typeRaw">The raw <c>--type</c> value.</param>
/// <param name="emailsRaw">The raw <c>--emails</c> value, or null when omitted.</param>
/// <param name="phonesRaw">The raw <c>--phones</c> value, or null when omitted.</param>
/// <returns>The resolved channel and recipient lists (the off-channel list is empty).</returns>
/// <exception cref="ArgumentException">Thrown when the type is unknown or the recipient flags are inconsistent with the channel.</exception>
private static (NotificationType Type, IReadOnlyList<string> Emails, IReadOnlyList<string>? Phones) ResolveListChannel(
string typeRaw, string? emailsRaw, string? phonesRaw)
{
if (!Enum.TryParse<NotificationType>(typeRaw, ignoreCase: true, out var type))
{
throw new ArgumentException($"Invalid --type '{typeRaw}'. Expected 'email' or 'sms'.");
}
var emails = SplitRecipients(emailsRaw);
var phones = SplitRecipients(phonesRaw);
if (type == NotificationType.Email)
{
if (phones.Count > 0)
{
throw new ArgumentException("--phones is not valid for --type email; use --emails.");
}
if (emails.Count == 0)
{
throw new ArgumentException("--emails is required for --type email.");
}
return (type, emails, null);
}
// Sms
if (emails.Count > 0)
{
throw new ArgumentException("--emails is not valid for --type sms; use --phones.");
}
if (phones.Count == 0)
{
throw new ArgumentException("--phones is required for --type sms.");
}
return (type, Array.Empty<string>(), phones);
}
private static IReadOnlyList<string> SplitRecipients(string? raw) =>
string.IsNullOrWhiteSpace(raw)
? Array.Empty<string>()
: raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
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;
}
}