From fe94e99801b5fd0aaab999dbb0fe715f01c7f44a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 22:31:47 +0200 Subject: [PATCH] Print the mnemonic and the vault and unlocker listings to stdout (closes #138) `secret generate mnemonic`, `secret vault list` and `secret unlocker list`, in text and `--json` form, printed through cobra's `cmd.Println` and `cmd.Printf`. Those write to stderr when no output writer is set, and the binary sets none, so a pipe or `$(...)` got nothing. They now write to `cmd.OutOrStdout()`, as `secret list` does, and so does "No versions found" from `secret version list`. Status messages and the warning printed after the mnemonic stay on stderr. A new test runs the built binary and reads the stdout of `generate mnemonic` and of both listings in both forms. Model: opus-5-5 --- README.md | 3 +- TODO.md | 8 ++++ internal/cli/generate.go | 2 +- internal/cli/stdout_stderr_test.go | 65 ++++++++++++++++++++++++++++++ internal/cli/unlockers.go | 17 ++++---- internal/cli/vault.go | 12 +++--- internal/cli/version.go | 2 +- 7 files changed, 94 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 72f4964..4651c36 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ for confirmation, naming the version, the secret and its vault (see #### `secret generate mnemonic` -Generates a cryptographically secure BIP39 mnemonic phrase. +Generates a cryptographically secure BIP39 mnemonic phrase and prints it to +stdout. The warning to keep it safe goes to stderr. #### `secret generate secret [--length=16] [--type=base58] [--force]` diff --git a/TODO.md b/TODO.md index b608afe..857a05f 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,14 @@ https://git.eeqj.de/sneak/secret/milestone/12 # Completed Steps +- 2026-10-14: `secret generate mnemonic`, `secret vault list` and + `secret unlocker list` print their output to stdout, as `secret list` does, so + it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138). + They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to + stderr when no output writer is set, so `secret vault list --json | jq` + received nothing. "No versions found" from `secret version list` moved to + stdout too. Messages such as "Created vault", and the warning after the + mnemonic, stay on stderr. - 2026-10-14: Removing the current unlocker no longer leaves the vault unable to open through the unlockers it still has (https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on diff --git a/internal/cli/generate.go b/internal/cli/generate.go index e02dab0..631e6bd 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -103,7 +103,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error { } // Output mnemonic to stdout - cmd.Println(mnemonic) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic) // Output helpful information to stderr fmt.Fprintln(os.Stderr, "") diff --git a/internal/cli/stdout_stderr_test.go b/internal/cli/stdout_stderr_test.go index f1937fc..6f89366 100644 --- a/internal/cli/stdout_stderr_test.go +++ b/internal/cli/stdout_stderr_test.go @@ -2,6 +2,7 @@ package cli_test import ( "bytes" + "encoding/json" "os/exec" "path/filepath" "strings" @@ -9,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/secret/internal/bip39" "sneak.berlin/go/secret/internal/secret" ) @@ -90,3 +92,66 @@ func TestGetCommandOutputsToStdout(t *testing.T) { // Nothing should be in stderr assert.Empty(t, stderr.String(), "stderr should be empty") } + +// TestMnemonicAndListingsOutputToStdout tests that 'secret generate +// mnemonic', 'secret vault list' and 'secret unlocker list' print their +// output to stdout, so that it can be piped +func TestMnemonicAndListingsOutputToStdout(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + secretPath := secretBinaryPath(t) + + // run runs the secret binary and returns what it wrote to stdout and to + // stderr + run := func(args ...string) (string, string) { + //nolint:gosec // G204: test executes the freshly built secret binary + cmd := exec.CommandContext(t.Context(), secretPath, args...) + cmd.Env = []string{ + secret.EnvStateDir + "=" + tempDir, + secret.EnvMnemonic + "=" + testMnemonic, + secret.EnvUnlockPassphrase + "=test-passphrase", + "PATH=/usr/bin:/bin", + } + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + require.NoError(t, err, "%v should succeed: %s", args, stderr.String()) + + return stdout.String(), stderr.String() + } + + stdout, stderr := run("generate", "mnemonic") + assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)), + "stdout should hold the mnemonic, not %q", stdout) + assert.Contains(t, stderr, "Save this mnemonic phrase securely", + "the warning should be on stderr") + + run("init") + + stdout, _ = run("vault", "list", "--json") + + var vaults map[string]any + + require.NoError(t, json.Unmarshal([]byte(stdout), &vaults), + "stdout should hold the vaults as JSON, not %q", stdout) + assert.Equal(t, "default", vaults["currentVault"]) + + stdout, _ = run("unlocker", "list", "--json") + + var unlockers map[string]any + + require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers), + "stdout should hold the unlockers as JSON, not %q", stdout) + assert.Len(t, unlockers["unlockers"], 1) + + stdout, _ = run("vault", "list") + assert.Contains(t, stdout, "default (current)") + + stdout, _ = run("unlocker", "list") + assert.Contains(t, stdout, "Total: 1 unlocker(s)") +} diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index d354ce2..0a03189 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -326,22 +326,25 @@ func (cli *Instance) printUnlockersJSON( return fmt.Errorf("failed to marshal JSON: %w", err) } - cli.cmd.Println(string(jsonBytes)) + _, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes)) return nil } // printUnlockersTable prints unlockers in a formatted table func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error { + out := cli.cmd.OutOrStdout() + if len(unlockers) == 0 { - cli.cmd.Println("No unlockers found in current vault.") - cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.") + _, _ = fmt.Fprintln(out, "No unlockers found in current vault.") + _, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.") return nil } - cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS") - cli.cmd.Printf(" %-40s %-12s %-20s %s\n", + _, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n", + "UNLOCKER ID", "TYPE", "CREATED", "FLAGS") + _, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n", strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth), strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth)) @@ -356,7 +359,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error { prefix = "* " } - cli.cmd.Printf("%s%-40s %-12s %-20s %s\n", + _, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n", prefix, unlocker.ID, unlocker.Type, @@ -364,7 +367,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error { flags) } - cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers)) + _, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers)) return nil } diff --git a/internal/cli/vault.go b/internal/cli/vault.go index 0db3ffa..d88e051 100644 --- a/internal/cli/vault.go +++ b/internal/cli/vault.go @@ -186,6 +186,8 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { return err } + out := cmd.OutOrStdout() + if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic // Get current vault name for context currentVault := "" @@ -205,13 +207,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { return err } - cmd.Println(string(jsonBytes)) + _, _ = fmt.Fprintln(out, string(jsonBytes)) } else { // Text output - cmd.Println("Available vaults:") + _, _ = fmt.Fprintln(out, "Available vaults:") if len(vaults) == 0 { - cmd.Println(" (none)") + _, _ = fmt.Fprintln(out, " (none)") } else { // Try to get current vault for marking currentVault := "" @@ -223,9 +225,9 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error { for _, vaultName := range vaults { if vaultName == currentVault { - cmd.Printf(" %s (current)\n", vaultName) + _, _ = fmt.Fprintf(out, " %s (current)\n", vaultName) } else { - cmd.Printf(" %s\n", vaultName) + _, _ = fmt.Fprintf(out, " %s\n", vaultName) } } } diff --git a/internal/cli/version.go b/internal/cli/version.go index 415e28e..1304143 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -167,7 +167,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error { } if len(versions) == 0 { - cmd.Println("No versions found") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found") return nil }