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:
parent
70d19d09d0
commit
a73a409fe4
@ -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.
|
5
Makefile
5
Makefile
@ -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
|
||||
|
18
README.md
18
README.md
@ -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
|
||||
|
@ -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)) {
|
||||
|
@ -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())
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user