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
This commit is contained in:
Joseph Doherty
2026-11-13 00:46:17 -05:00
parent 1e98997ce1
commit e2f433e849
19 changed files with 724 additions and 52 deletions
@@ -182,9 +182,11 @@ public static class NotificationCommands
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), result.GetValue(SmtpCredentialsOption),
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption),
ValueSourceOptions.GetValue(result, SmtpCredentialsOption, SmtpCredentialsFileOption),
result.GetValue(SmtpOAuth2AuthorityOption), result.GetValue(SmtpOAuth2ScopeOption), result.GetValue(SmtpTransportOption))));
group.Add(createCmd);
@@ -199,6 +201,7 @@ public static class NotificationCommands
updateCmd.Add(SmtpOAuth2AuthorityOption);
updateCmd.Add(SmtpOAuth2ScopeOption);
updateCmd.Add(SmtpTransportOption);
ValueSourceOptions.AddFileOptions(updateCmd, (SmtpCredentialsOption, SmtpCredentialsFileOption, false));
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -229,6 +232,9 @@ public static class NotificationCommands
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")
{
@@ -282,7 +288,7 @@ public static class NotificationCommands
var authMode = result.GetValue(SmtpAuthModeOption)!;
var from = result.GetValue(SmtpFromOption)!;
var tlsMode = result.GetValue(SmtpTlsModeOption);
var credentials = result.GetValue(SmtpCredentialsOption);
var credentials = ValueSourceOptions.GetValue(result, SmtpCredentialsOption, SmtpCredentialsFileOption);
var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption);
var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption);
var transport = result.GetValue(SmtpTransportOption);
@@ -304,15 +310,18 @@ public static class NotificationCommands
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 =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption))) result.AddError("--auth-token is required when creating SMS configuration.");
// 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), result.GetValue(SmsAuthTokenOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsApiBaseUrlOption), ValueSourceOptions.GetValue(result, SmsAuthTokenOption, SmsAuthTokenFileOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result))));
group.Add(createCmd);
@@ -326,6 +335,7 @@ public static class NotificationCommands
updateCmd.Add(SmsTimeoutOption);
updateCmd.Add(SmsMaxRetriesOption);
updateCmd.Add(SmsRetryDelayOption);
ValueSourceOptions.AddFileOptions(updateCmd, (SmsAuthTokenOption, SmsAuthTokenFileOption, false));
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -381,6 +391,9 @@ public static class NotificationCommands
{
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>
@@ -398,7 +411,7 @@ public static class NotificationCommands
var fromNumber = result.GetValue(SmsFromNumberOption);
var messagingServiceSid = result.GetValue(SmsMessagingServiceSidOption);
var apiBaseUrl = result.GetValue(SmsApiBaseUrlOption);
var authToken = result.GetValue(SmsAuthTokenOption);
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).