Refactor unlockers command structure and add quiet flag to list command

- Rename 'unlockers' command to 'unlocker' for consistency
- Move all unlocker subcommands (list, add, remove) under single 'unlocker' command
- Add --quiet/-q flag to 'secret list' for scripting support
- Update documentation and tests to reflect command changes

The quiet flag outputs only secret names without headers or formatting,
making it ideal for shell script usage like: secret get $(secret list -q | head -1)
This commit is contained in:
Jeffrey Paul 2025-07-22 16:04:44 +02:00
parent 70d19d09d0
commit a73a409fe4
7 changed files with 125 additions and 48 deletions

View File

@ -26,3 +26,5 @@ Read the rules in AGENTS.md and follow them.
* Do not stop working on a task until you have reached the definition of * Do not stop working on a task until you have reached the definition of
done provided to you in the initial instruction. Don't do part or most of done provided to you in the initial instruction. Don't do part or most of
the work, do all of the work until the criteria for done are met. the work, do all of the work until the criteria for done are met.
* When you complete each task, if the tests are passing and the code is formatted and there are no linter errors, always commit and push your work. Use a good commit message and don't mention any author or co-author attribution.

View File

@ -11,8 +11,7 @@ default: check
build: ./secret build: ./secret
# Simple build (no code signing needed) ./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
./secret:
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
vet: vet:

View File

@ -132,10 +132,10 @@ Generates and stores a random secret.
### Unlocker Management ### Unlocker Management
#### `secret unlockers list [--json]` / `secret unlockers ls` #### `secret unlocker list [--json]` / `secret unlocker ls`
Lists all unlockers in the current vault with their metadata. Lists all unlockers in the current vault with their metadata.
#### `secret unlockers add <type> [options]` #### `secret unlocker add <type> [options]`
Creates a new unlocker of the specified type: Creates a new unlocker of the specified type:
**Types:** **Types:**
@ -146,7 +146,7 @@ Creates a new unlocker of the specified type:
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (required for PGP type) - `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret unlockers remove <unlocker-id> [--force]` / `secret unlockers rm` ⚠️ 🛑 #### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command does not ask for confirmation. **DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command does not ask for confirmation.
Cannot remove the last unlocker if the vault has secrets unless --force is used. Cannot remove the last unlocker if the vault has secrets unless --force is used.
- `--force, -f`: Force removal of last unlocker even if vault has secrets - `--force, -f`: Force removal of last unlocker even if vault has secrets
@ -308,7 +308,7 @@ secret vault create personal
# Work with work vault # Work with work vault
secret vault select work secret vault select work
echo "work-db-pass" | secret add database/password echo "work-db-pass" | secret add database/password
secret unlockers add passphrase # Add passphrase authentication secret unlocker add passphrase # Add passphrase authentication
# Switch to personal vault # Switch to personal vault
secret vault select personal secret vault select personal
@ -324,18 +324,18 @@ secret vault remove personal --force
### Advanced Authentication ### Advanced Authentication
```bash ```bash
# Add multiple unlock methods # Add multiple unlock methods
secret unlockers add passphrase # Password-based secret unlocker add passphrase # Password-based
secret unlockers add pgp --keyid ABCD1234 # GPG key secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlockers add keychain # macOS Keychain (macOS only) secret unlocker add keychain # macOS Keychain (macOS only)
# List unlockers # List unlockers
secret unlockers list secret unlocker list
# Select a specific unlocker # Select a specific unlocker
secret unlocker select <unlocker-id> secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!) # Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
secret unlockers remove <unlocker-id> secret unlocker remove <unlocker-id>
``` ```
### Version Management ### Version Management

View File

@ -177,6 +177,12 @@ func TestSecretManagerIntegration(t *testing.T) {
// Expected: Shows database/password with metadata // Expected: Shows database/password with metadata
test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin) test11ListSecrets(t, testMnemonic, runSecret, runSecretWithStdin)
// Test 11b: List secrets with quiet flag
// Command: secret list -q
// Purpose: Test quiet output for scripting
// Expected: Only secret names, no headers or formatting
test11bListSecretsQuiet(t, testMnemonic, runSecret)
// Test 12: Add secrets with different name formats // Test 12: Add secrets with different name formats
// Commands: Various secret names (paths, dots, underscores) // Commands: Various secret names (paths, dots, underscores)
// Purpose: Test secret name validation and storage encoding // Purpose: Test secret name validation and storage encoding
@ -184,7 +190,7 @@ func TestSecretManagerIntegration(t *testing.T) {
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin) test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
// Test 13: Unlocker management // Test 13: Unlocker management
// Commands: secret unlockers list, secret unlockers add pgp // Commands: secret unlocker list, secret unlocker add pgp
// Purpose: Test multiple unlocker types // Purpose: Test multiple unlocker types
// Expected filesystem: // Expected filesystem:
// - Multiple directories under unlockers.d/ // - Multiple directories under unlockers.d/
@ -901,6 +907,81 @@ func test11ListSecrets(t *testing.T, testMnemonic string, runSecret func(...stri
assert.True(t, secretNames["database/password"], "should have database/password") assert.True(t, secretNames["database/password"], "should have database/password")
} }
func test11bListSecretsQuiet(t *testing.T, testMnemonic string, runSecret func(...string) (string, error)) {
// Test quiet output
quietOutput, err := runSecret("list", "-q")
require.NoError(t, err, "secret list -q should succeed")
// Split output into lines
lines := strings.Split(strings.TrimSpace(quietOutput), "\n")
// Should have exactly 3 lines (3 secrets)
assert.Len(t, lines, 3, "quiet output should have exactly 3 lines")
// Should not contain any headers or formatting
assert.NotContains(t, quietOutput, "Secrets in vault", "should not have vault header")
assert.NotContains(t, quietOutput, "NAME", "should not have NAME header")
assert.NotContains(t, quietOutput, "LAST UPDATED", "should not have LAST UPDATED header")
assert.NotContains(t, quietOutput, "Total:", "should not have total count")
assert.NotContains(t, quietOutput, "----", "should not have separator lines")
// Should contain exactly the secret names
secretNames := make(map[string]bool)
for _, line := range lines {
secretNames[line] = true
}
assert.True(t, secretNames["api/key"], "should have api/key")
assert.True(t, secretNames["config/database.yaml"], "should have config/database.yaml")
assert.True(t, secretNames["database/password"], "should have database/password")
// Test quiet output with filter
quietFilterOutput, err := runSecret("list", "database", "-q")
require.NoError(t, err, "secret list with filter and -q should succeed")
// Should only show secrets matching filter
filteredLines := strings.Split(strings.TrimSpace(quietFilterOutput), "\n")
assert.Len(t, filteredLines, 2, "quiet filtered output should have exactly 2 lines")
// Verify filtered results
filteredSecrets := make(map[string]bool)
for _, line := range filteredLines {
filteredSecrets[line] = true
}
assert.True(t, filteredSecrets["config/database.yaml"], "should have config/database.yaml")
assert.True(t, filteredSecrets["database/password"], "should have database/password")
assert.False(t, filteredSecrets["api/key"], "should not have api/key")
// Test that quiet and JSON flags are mutually exclusive behavior
// (JSON should take precedence if both are specified)
jsonQuietOutput, err := runSecret("list", "--json", "-q")
require.NoError(t, err, "secret list --json -q should succeed")
// Should be valid JSON, not quiet output
var jsonResponse map[string]interface{}
err = json.Unmarshal([]byte(jsonQuietOutput), &jsonResponse)
assert.NoError(t, err, "output should be valid JSON when both flags are used")
// Test using quiet output in command substitution would work like:
// secret get $(secret list -q | head -1)
// We'll simulate this by getting the first secret name
firstSecret := lines[0]
// Need to create a runSecretWithEnv to provide mnemonic for get operation
runSecretWithEnv := func(env map[string]string, args ...string) (string, error) {
return cli.ExecuteCommandInProcess(args, "", env)
}
getOutput, err := runSecretWithEnv(map[string]string{
"SB_SECRET_MNEMONIC": testMnemonic,
}, "get", firstSecret)
require.NoError(t, err, "get with secret name from quiet output should succeed")
// Verify we got a value (not empty)
assert.NotEmpty(t, getOutput, "should retrieve a non-empty secret value")
}
func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
// Make sure we're in default vault // Make sure we're in default vault
runSecret := func(args ...string) (string, error) { runSecret := func(args ...string) (string, error) {
@ -1016,9 +1097,9 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
require.NoError(t, err, "vault select should succeed") require.NoError(t, err, "vault select should succeed")
// List unlockers // List unlockers
output, err := runSecret("unlockers", "list") output, err := runSecret("unlocker", "list")
require.NoError(t, err, "unlockers list should succeed") require.NoError(t, err, "unlocker list should succeed")
t.Logf("DEBUG: unlockers list output: %q", output) t.Logf("DEBUG: unlocker list output: %q", output)
// Should have the passphrase unlocker created during init // Should have the passphrase unlocker created during init
assert.Contains(t, output, "passphrase", "should have passphrase unlocker") assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
@ -1027,15 +1108,15 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
output, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
"SB_UNLOCK_PASSPHRASE": "another-passphrase", "SB_UNLOCK_PASSPHRASE": "another-passphrase",
"SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key "SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key
}, "unlockers", "add", "passphrase") }, "unlocker", "add", "passphrase")
if err != nil { if err != nil {
t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output) t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output)
} }
require.NoError(t, err, "add passphrase unlocker should succeed") require.NoError(t, err, "add passphrase unlocker should succeed")
// List unlockers again - should have 2 now // List unlockers again - should have 2 now
output, err = runSecret("unlockers", "list") output, err = runSecret("unlocker", "list")
require.NoError(t, err, "unlockers list should succeed") require.NoError(t, err, "unlocker list should succeed")
// Count passphrase unlockers // Count passphrase unlockers
lines := strings.Split(output, "\n") lines := strings.Split(output, "\n")
@ -1051,8 +1132,8 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker") assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
// Test JSON output // Test JSON output
jsonOutput, err := runSecret("unlockers", "list", "--json") jsonOutput, err := runSecret("unlocker", "list", "--json")
require.NoError(t, err, "unlockers list --json should succeed") require.NoError(t, err, "unlocker list --json should succeed")
var response map[string]interface{} var response map[string]interface{}
err = json.Unmarshal([]byte(jsonOutput), &response) err = json.Unmarshal([]byte(jsonOutput), &response)
@ -1536,10 +1617,10 @@ func test22JSONOutput(t *testing.T, runSecret func(...string) (string, error)) {
// Test secret list --json (already tested in test 11) // Test secret list --json (already tested in test 11)
// Test unlockers list --json (already tested in test 13) // Test unlocker list --json (already tested in test 13)
// All JSON outputs verified to be valid and contain expected fields // All JSON outputs verified to be valid and contain expected fields
t.Log("JSON output formats verified for vault list, secret list, and unlockers list") t.Log("JSON output formats verified for vault list, secret list, and unlocker list")
} }
func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {

View File

@ -35,7 +35,6 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newGetCmd()) cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd()) cmd.AddCommand(newListCmd())
cmd.AddCommand(newRemoveCmd()) cmd.AddCommand(newRemoveCmd())
cmd.AddCommand(newUnlockersCmd())
cmd.AddCommand(newUnlockerCmd()) cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd()) cmd.AddCommand(newImportCmd())
cmd.AddCommand(newEncryptCmd()) cmd.AddCommand(newEncryptCmd())

View File

@ -65,6 +65,7 @@ func newListCmd() *cobra.Command {
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
quietOutput, _ := cmd.Flags().GetBool("quiet")
var filter string var filter string
if len(args) > 0 { if len(args) > 0 {
@ -73,11 +74,12 @@ func newListCmd() *cobra.Command {
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.ListSecrets(cmd, jsonOutput, filter) return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
}, },
} }
cmd.Flags().Bool("json", false, "Output in JSON format") cmd.Flags().Bool("json", false, "Output in JSON format")
cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)")
return cmd return cmd
} }
@ -284,7 +286,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
} }
// ListSecrets lists all secrets in the current vault // ListSecrets lists all secrets in the current vault
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error { func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@ -341,6 +343,11 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
} }
cmd.Println(string(jsonBytes)) cmd.Println(string(jsonBytes))
} else if quietOutput {
// Quiet output - just secret names
for _, secretName := range filteredSecrets {
cmd.Println(secretName)
}
} else { } else {
// Pretty table output // Pretty table output
if len(filteredSecrets) == 0 { if len(filteredSecrets) == 0 {

View File

@ -56,21 +56,22 @@ func getDefaultGPGKey() (string, error) {
return "", fmt.Errorf("no GPG secret keys found") return "", fmt.Errorf("no GPG secret keys found")
} }
func newUnlockersCmd() *cobra.Command { func newUnlockerCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "unlockers", Use: "unlocker",
Short: "Manage unlockers", Short: "Manage unlockers",
Long: `Create, list, and remove unlockers for the current vault.`, Long: `Create, list, and remove unlockers for the current vault.`,
} }
cmd.AddCommand(newUnlockersListCmd()) cmd.AddCommand(newUnlockerListCmd())
cmd.AddCommand(newUnlockersAddCmd()) cmd.AddCommand(newUnlockerAddCmd())
cmd.AddCommand(newUnlockersRemoveCmd()) cmd.AddCommand(newUnlockerRemoveCmd())
cmd.AddCommand(newUnlockerSelectCmd())
return cmd return cmd
} }
func newUnlockersListCmd() *cobra.Command { func newUnlockerListCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list", Use: "list",
Aliases: []string{"ls"}, Aliases: []string{"ls"},
@ -90,7 +91,7 @@ func newUnlockersListCmd() *cobra.Command {
return cmd return cmd
} }
func newUnlockersAddCmd() *cobra.Command { func newUnlockerAddCmd() *cobra.Command {
// Build the supported types list based on platform // Build the supported types list based on platform
supportedTypes := "passphrase, pgp" supportedTypes := "passphrase, pgp"
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
@ -120,7 +121,7 @@ func newUnlockersAddCmd() *cobra.Command {
return cmd return cmd
} }
func newUnlockersRemoveCmd() *cobra.Command { func newUnlockerRemoveCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "remove <unlocker-id>", Use: "remove <unlocker-id>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
@ -142,19 +143,7 @@ func newUnlockersRemoveCmd() *cobra.Command {
return cmd return cmd
} }
func newUnlockerCmd() *cobra.Command { func newUnlockerSelectCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "unlocker",
Short: "Manage current unlocker",
Long: `Select the current unlocker for operations.`,
}
cmd.AddCommand(newUnlockerSelectSubCmd())
return cmd
}
func newUnlockerSelectSubCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "select <unlocker-id>", Use: "select <unlocker-id>",
Short: "Select an unlocker as current", Short: "Select an unlocker as current",
@ -274,7 +263,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Pretty table output // Pretty table output
if len(unlockers) == 0 { if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.") cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.") cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
return nil return nil
} }