// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using System.Text; namespace Progmasoft.Progmaweb.Api.Accounts; /// /// The command that provisions a reserved account on the server: accounts provision-reserved <Name> /// <email>. Public registration refuses the reserved names (Progmasoft, Leitwolf); this is the trusted /// operations tooling that creates them. The password is read from the terminal without echo, twice, or from one line /// of standard input when it is not a terminal; it never appears on a command line. /// internal static class AccountCommand { /// Runs an account command. /// The services of the API, not started. /// The arguments after accounts. /// Where the password comes from. /// Where results go. /// Where errors go. /// Cancels the command. /// The exit code: 0 on success, 1 when refused, 2 for a wrong command line. public static async Task RunAsync( IServiceProvider services, string[] args, IPasswordInput input, TextWriter output, TextWriter errors, CancellationToken cancellationToken) { if (args is not ["provision-reserved", string name, string email]) { await errors.WriteLineAsync("usage: accounts provision-reserved "); return 2; } // The service brings the account tables to the current schema when it starts; a command may run before. if (services.GetService() is { } dataSource) { await AccountSchema.ApplyAsync(dataSource, cancellationToken); } string? password = input.Read("Password: "); if (input.IsTerminal && password != input.Read("Password again: ")) { await errors.WriteLineAsync("The two passwords differ; nothing was created."); return 1; } AccountService accounts = services.GetRequiredService(); (AccountRecord? account, string? error) = await accounts.ProvisionReservedAsync(name, email, password, cancellationToken); if (account is null) { await errors.WriteLineAsync(error); return 1; } await output.WriteLineAsync($"Created the reserved account {account.AccountName} ({account.Email})."); return 0; } } /// Where reads a password from. internal interface IPasswordInput { /// Whether the input is a terminal, which asks twice and does not echo. bool IsTerminal { get; } /// Reads one password. /// What to show before it, on a terminal. /// The password, or at the end of the input. string? Read(string prompt); } /// The console: a terminal without echo, or one line of redirected standard input. internal sealed class ConsolePasswordInput : IPasswordInput { /// public bool IsTerminal => !Console.IsInputRedirected; /// public string? Read(string prompt) { if (!IsTerminal) { return Console.In.ReadLine(); } Console.Error.Write(prompt); StringBuilder password = new(); while (true) { ConsoleKeyInfo key = Console.ReadKey(intercept: true); if (key.Key == ConsoleKey.Enter) { Console.Error.WriteLine(); return password.ToString(); } if (key.Key == ConsoleKey.Backspace) { if (password.Length > 0) { password.Length--; } continue; } if (!char.IsControl(key.KeyChar)) { password.Append(key.KeyChar); } } } }