feat(secrets-cli): launch interactive console on bare secret in a TTY
This commit is contained in:
@@ -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.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Spectre.Console;
|
||||||
using ZB.MOM.WW.Secrets.Abstractions;
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
using ZB.MOM.WW.Secrets.Cli;
|
using ZB.MOM.WW.Secrets.Cli;
|
||||||
|
using ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
using ZB.MOM.WW.Secrets.MasterKey;
|
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
|
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
|
||||||
// migration, parses the verb + positional args, and dispatches.
|
// 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);
|
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||||
builder.Configuration
|
builder.Configuration
|
||||||
.AddJsonFile("appsettings.json", optional: true)
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
using Spectre.Console.Testing;
|
||||||
|
using ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the composition root of the interactive console: the non-TTY gate that decides whether a bare
|
||||||
|
/// <c>secret</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user