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
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.
* 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
# Simple build (no code signing needed)
./secret:
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
vet:
@ -30,7 +29,7 @@ lint:
check: build test
# Build Docker container
docker:
docker:
docker build -t sneak/secret .
# Run Docker container interactively

View File

@ -132,10 +132,10 @@ Generates and stores a random secret.
### 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.
#### `secret unlockers add <type> [options]`
#### `secret unlocker add <type> [options]`
Creates a new unlocker of the specified type:
**Types:**
@ -146,7 +146,7 @@ Creates a new unlocker of the specified type:
**Options:**
- `--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.
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
@ -308,7 +308,7 @@ secret vault create personal
# Work with work vault
secret vault select work
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
secret vault select personal
@ -324,18 +324,18 @@ secret vault remove personal --force
### Advanced Authentication
```bash
# Add multiple unlock methods
secret unlockers add passphrase # Password-based
secret unlockers add pgp --keyid ABCD1234 # GPG key
secret unlockers add keychain # macOS Keychain (macOS only)
secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only)
# List unlockers
secret unlockers list
secret unlocker list
# Select a specific unlocker
secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
secret unlockers remove <unlocker-id>
secret unlocker remove <unlocker-id>
```
### Version Management

View File

@ -177,6 +177,12 @@ func TestSecretManagerIntegration(t *testing.T) {
// Expected: Shows database/password with metadata
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
// Commands: Various secret names (paths, dots, underscores)
// Purpose: Test secret name validation and storage encoding
@ -184,7 +190,7 @@ func TestSecretManagerIntegration(t *testing.T) {
test12SecretNameFormats(t, tempDir, testMnemonic, runSecretWithEnv, runSecretWithStdin)
// 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
// Expected filesystem:
// - 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")
}
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)) {
// Make sure we're in default vault
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")
// List unlockers
output, err := runSecret("unlockers", "list")
require.NoError(t, err, "unlockers list should succeed")
t.Logf("DEBUG: unlockers list output: %q", output)
output, err := runSecret("unlocker", "list")
require.NoError(t, err, "unlocker list should succeed")
t.Logf("DEBUG: unlocker list output: %q", output)
// Should have the passphrase unlocker created during init
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{
"SB_UNLOCK_PASSPHRASE": "another-passphrase",
"SB_SECRET_MNEMONIC": testMnemonic, // Need mnemonic to get long-term key
}, "unlockers", "add", "passphrase")
}, "unlocker", "add", "passphrase")
if err != nil {
t.Logf("Error adding passphrase unlocker: %v, output: %s", err, output)
}
require.NoError(t, err, "add passphrase unlocker should succeed")
// List unlockers again - should have 2 now
output, err = runSecret("unlockers", "list")
require.NoError(t, err, "unlockers list should succeed")
output, err = runSecret("unlocker", "list")
require.NoError(t, err, "unlocker list should succeed")
// Count passphrase unlockers
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")
// Test JSON output
jsonOutput, err := runSecret("unlockers", "list", "--json")
require.NoError(t, err, "unlockers list --json should succeed")
jsonOutput, err := runSecret("unlocker", "list", "--json")
require.NoError(t, err, "unlocker list --json should succeed")
var response map[string]interface{}
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 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
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)) {

View File

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

View File

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

View File

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