feat(secrets-cli): launch interactive console on bare secret in a TTY

This commit is contained in:
Joseph Doherty
2026-07-19 10:30:30 -04:00
parent 7e67bd61c7
commit 1f7d26d4d9
3 changed files with 127 additions and 0 deletions
@@ -0,0 +1,62 @@
using Spectre.Console;
using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
/// <summary>
/// Composition root for the interactive console: the non-TTY gate that decides whether a bare
/// <c>secret</c> invocation launches the console, and the default wiring (flow set, recents store,
/// target reader, session factory) that <see cref="Program"/> hands to an <see cref="InteractiveShell"/>.
/// </summary>
public static class InteractiveEntry
{
/// <summary>
/// Whether a bare <c>secret</c> invocation should launch the interactive console. True only when no
/// verb was supplied AND neither standard stream is redirected — a redirected stdin/stdout means the
/// invocation is scripted/piped, where the console's prompts would hang or corrupt the pipe, so the
/// caller falls through to headless usage instead.
/// </summary>
/// <param name="args">The process command-line arguments.</param>
/// <param name="inputRedirected"><see cref="Console.IsInputRedirected"/> at process start.</param>
/// <param name="outputRedirected"><see cref="Console.IsOutputRedirected"/> at process start.</param>
public static bool ShouldEnterInteractive(string[] args, bool inputRedirected, bool outputRedirected)
=> args.Length == 0 && !inputRedirected && !outputRedirected;
/// <summary>
/// Builds the ordered flow set the shell is wired with — one instance of each menu action, in the
/// order they appear on the menu. Exposed (and used by <see cref="CreateShell"/>) so the composition
/// is assertable without running the shell.
/// </summary>
public static IReadOnlyList<IInteractiveFlow> CreateFlows() =>
[
new ListSecretsFlow(),
new SetSecretFlow(),
new RevealSecretFlow(),
new DeleteSecretFlow(),
new ReferenceAuditFlow(),
new KekDoctorFlow(),
new BundleExportFlow(),
new BundleImportFlow(),
];
/// <summary>
/// Wires an <see cref="InteractiveShell"/> with its default collaborators: a per-user recents store
/// under <c>~/.zb-secrets/recent-targets.json</c>, the target-config reader, the session factory, and
/// <see cref="CreateFlows"/>. The shell's own ctor re-validates flow-title uniqueness.
/// </summary>
/// <param name="console">The console surface the shell (and its flows) render to.</param>
public static InteractiveShell CreateShell(IAnsiConsole console)
{
string recentsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".zb-secrets",
"recent-targets.json");
return new InteractiveShell(
console,
new RecentTargetsStore(recentsPath),
new TargetConfigReader(),
new SecretsSessionFactory(),
CreateFlows());
}
}
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey;
@@ -10,6 +12,12 @@ using ZB.MOM.WW.Secrets.MasterKey;
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
// migration, parses the verb + positional args, and dispatches.
// A bare `secret` on a real TTY launches the interactive console, which builds its OWN per-target
// sessions — so this must run BEFORE the host build + schema migration below, which the headless
// verbs (and only they) require against the CLI's own appsettings-configured store and KEK.
if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(CancellationToken.None);
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)