diff --git a/CLAUDE.md b/CLAUDE.md index fc06c5c..595385d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. \ No newline at end of file diff --git a/Makefile b/Makefile index 1e5a9ea..87eecc0 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index f7eeb0b..71069c1 100644 --- a/README.md +++ b/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 [options]` +#### `secret unlocker add [options]` Creates a new unlocker of the specified type: **Types:** @@ -146,7 +146,7 @@ Creates a new unlocker of the specified type: **Options:** - `--keyid `: GPG key ID (required for PGP type) -#### `secret unlockers remove [--force]` / `secret unlockers rm` ⚠️ 🛑 +#### `secret unlocker remove [--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 # Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!) -secret unlockers remove +secret unlocker remove ``` ### Version Management diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9474884..70b1d3a 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -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)) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 701e65f..ceccff5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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()) diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index b1feea3..b1d755f 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -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 { diff --git a/internal/cli/unlockers.go b/internal/cli/unlockers.go index 5dcb61a..5448ff2 100644 --- a/internal/cli/unlockers.go +++ b/internal/cli/unlockers.go @@ -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 ", 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 ", 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 }