diff --git a/README.md b/README.md index 0bb262a..83522b1 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 cfc97da..946fd9d 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: A `secret add --force` that cannot unlock the vault, given a wrong passphrase or mnemonic, leaves the secret as it was (https://git.eeqj.de/sneak/secret/issues/136). The new version used to be 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 6792a0f..5a38bcb 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -323,22 +323,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)) @@ -353,7 +356,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, @@ -361,7 +364,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 }