From 1f7d26d4d95fc8a69628a85a322b11451e51f37a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 10:30:30 -0400 Subject: [PATCH] feat(secrets-cli): launch interactive console on bare secret in a TTY --- .../Interactive/InteractiveEntry.cs | 62 +++++++++++++++++++ .../src/ZB.MOM.WW.Secrets.Cli/Program.cs | 8 +++ .../Cli/Interactive/InteractiveEntryTests.cs | 57 +++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs new file mode 100644 index 0000000..a51ad57 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs @@ -0,0 +1,62 @@ +using Spectre.Console; +using ZB.MOM.WW.Secrets.Cli.Interactive.Flows; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// Composition root for the interactive console: the non-TTY gate that decides whether a bare +/// secret invocation launches the console, and the default wiring (flow set, recents store, +/// target reader, session factory) that hands to an . +/// +public static class InteractiveEntry +{ + /// + /// Whether a bare secret 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. + /// + /// The process command-line arguments. + /// at process start. + /// at process start. + public static bool ShouldEnterInteractive(string[] args, bool inputRedirected, bool outputRedirected) + => args.Length == 0 && !inputRedirected && !outputRedirected; + + /// + /// 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 ) so the composition + /// is assertable without running the shell. + /// + public static IReadOnlyList CreateFlows() => + [ + new ListSecretsFlow(), + new SetSecretFlow(), + new RevealSecretFlow(), + new DeleteSecretFlow(), + new ReferenceAuditFlow(), + new KekDoctorFlow(), + new BundleExportFlow(), + new BundleImportFlow(), + ]; + + /// + /// Wires an with its default collaborators: a per-user recents store + /// under ~/.zb-secrets/recent-targets.json, the target-config reader, the session factory, and + /// . The shell's own ctor re-validates flow-title uniqueness. + /// + /// The console surface the shell (and its flows) render to. + 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()); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs index 5423f81..223d255 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs @@ -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) diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs new file mode 100644 index 0000000..6a33d60 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs @@ -0,0 +1,57 @@ +using Spectre.Console.Testing; +using ZB.MOM.WW.Secrets.Cli.Interactive; + +namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; + +/// +/// Pins the composition root of the interactive console: the non-TTY gate that decides whether a bare +/// secret invocation launches the console, and the exact flow set (order + uniqueness) the shell +/// is wired with. These are the two things Program.cs delegates to, so they are asserted here directly +/// rather than by driving the whole shell. +/// +public sealed class InteractiveEntryTests +{ + [Theory] + // Only a bare invocation on a real TTY (neither stream redirected) enters the interactive console. + [InlineData(new string[0], false, false, true)] + [InlineData(new string[0], true, false, false)] + [InlineData(new string[0], false, true, false)] + [InlineData(new string[0], true, true, false)] + [InlineData(new[] { "list" }, false, false, false)] + public void ShouldEnterInteractive_true_only_for_no_args_and_real_tty( + string[] args, bool inputRedirected, bool outputRedirected, bool expected) + { + Assert.Equal( + expected, + InteractiveEntry.ShouldEnterInteractive(args, inputRedirected, outputRedirected)); + } + + [Fact] + public void CreateShell_composes_all_flows_exactly_once() + { + // CreateShell must succeed — its InteractiveShell ctor re-validates flow-title uniqueness, so a + // duplicate or reserved collision would throw here. + InteractiveShell shell = InteractiveEntry.CreateShell(new TestConsole()); + Assert.NotNull(shell); + + // The composed flow set is asserted through the same seam CreateShell uses. + string[] titles = InteractiveEntry.CreateFlows().Select(f => f.Title).ToArray(); + + Assert.Equal( + new[] + { + "List secrets", + "Set / rotate a secret", + "Get (reveal) a secret", + "Delete a secret", + "Reference audit & seed", + "KEK doctor (lockout recovery)", + "Export bundle (ciphertext-only)", + "Import bundle", + }, + titles); + + // Each title appears exactly once. + Assert.Equal(titles.Length, titles.Distinct(StringComparer.Ordinal).Count()); + } +}