From 9c7868ac49332dbcb24cddfa9ddaf30e2ee210a0 Mon Sep 17 00:00:00 2001 From: Roman Volykh Date: Thu, 30 Oct 2025 18:26:40 +0200 Subject: [PATCH 1/2] fix: Allow to save secrets without key --- internal/engines/aws/aws_secrets_manager.go | 40 +++++++++---- internal/engines/aws/aws_ssm.go | 62 ++++++++++----------- internal/engines/vault/vault_client.go | 14 +++++ internal/ui/forms/builder.go | 25 +++++---- 4 files changed, 85 insertions(+), 56 deletions(-) diff --git a/internal/engines/aws/aws_secrets_manager.go b/internal/engines/aws/aws_secrets_manager.go index 56a6e2b..cddf886 100644 --- a/internal/engines/aws/aws_secrets_manager.go +++ b/internal/engines/aws/aws_secrets_manager.go @@ -303,18 +303,26 @@ func (c *AWSClient) CreateSecret(path string, data map[string]any) error { secretName := strings.Trim(path, "/") - // Convert data map to JSON string - jsonData, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal secret data: %w", err) + var secretString string + // Check if data has empty key marker (omitted key case) + if value, hasEmptyKey := data[""]; hasEmptyKey && len(data) == 1 { + // Store value as plain string (no JSON conversion) + secretString = fmt.Sprintf("%v", value) + } else { + // Convert data map to JSON string + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal secret data: %w", err) + } + secretString = string(jsonData) } input := &secretsmanager.CreateSecretInput{ Name: aws.String(secretName), - SecretString: aws.String(string(jsonData)), + SecretString: aws.String(secretString), } - _, err = c.client.CreateSecretWithContext(ctx, input) + _, err := c.client.CreateSecretWithContext(ctx, input) if err != nil { return fmt.Errorf("failed to create secret '%s': %w", path, err) } @@ -329,18 +337,26 @@ func (c *AWSClient) UpdateSecret(path string, data map[string]any) error { secretName := strings.Trim(path, "/") - // Convert data map to JSON string - jsonData, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal secret data: %w", err) + var secretString string + // Check if data has empty key marker (omitted key case) + if value, hasEmptyKey := data[""]; hasEmptyKey && len(data) == 1 { + // Store value as plain string (no JSON conversion) + secretString = fmt.Sprintf("%v", value) + } else { + // Convert data map to JSON string + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal secret data: %w", err) + } + secretString = string(jsonData) } input := &secretsmanager.UpdateSecretInput{ SecretId: aws.String(secretName), - SecretString: aws.String(string(jsonData)), + SecretString: aws.String(secretString), } - _, err = c.client.UpdateSecretWithContext(ctx, input) + _, err := c.client.UpdateSecretWithContext(ctx, input) if err != nil { return fmt.Errorf("failed to update secret '%s': %w", path, err) } diff --git a/internal/engines/aws/aws_ssm.go b/internal/engines/aws/aws_ssm.go index 21e50c0..5375e5c 100644 --- a/internal/engines/aws/aws_ssm.go +++ b/internal/engines/aws/aws_ssm.go @@ -317,33 +317,28 @@ func (c *AWSSSMClient) CreateSecret(path string, data map[string]any) error { paramName = "/" + paramName } - // Convert data map to JSON string - jsonData, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal secret data: %w", err) - } - - // Determine parameter type - if data contains sensitive info, use SecureString - // For simplicity, we'll use SecureString for all secrets - paramType := "SecureString" - - // Check if the data suggests it's a plain string (single "value" key) - if len(data) == 1 { - if _, ok := data["value"]; ok { - // If it's a simple value, use String type - paramType = "String" - jsonData = []byte(fmt.Sprintf("%v", data["value"])) + var valueString string + // Check if data has empty key marker (omitted key case) + if value, hasEmptyKey := data[""]; hasEmptyKey && len(data) == 1 { + // Store value as plain string (no JSON conversion) + valueString = fmt.Sprintf("%v", value) + } else { + // Convert data map to JSON string + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal secret data: %w", err) } + valueString = string(jsonData) } input := &ssm.PutParameterInput{ Name: aws.String(paramName), - Value: aws.String(string(jsonData)), - Type: aws.String(paramType), + Value: aws.String(valueString), + Type: aws.String("SecureString"), Overwrite: aws.Bool(false), // Don't overwrite existing parameters } - _, err = c.client.PutParameterWithContext(ctx, input) + _, err := c.client.PutParameterWithContext(ctx, input) if err != nil { return fmt.Errorf("failed to create parameter '%s': %w", path, err) } @@ -362,34 +357,33 @@ func (c *AWSSSMClient) UpdateSecret(path string, data map[string]any) error { paramName = "/" + paramName } - // Convert data map to JSON string - jsonData, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("failed to marshal secret data: %w", err) - } - + paramType := "SecureString" // Get existing parameter to determine type getInput := &ssm.GetParameterInput{ Name: aws.String(paramName), } existingParam, err := c.client.GetParameterWithContext(ctx, getInput) - - paramType := "SecureString" if err == nil && existingParam != nil && existingParam.Parameter != nil && existingParam.Parameter.Type != nil { paramType = *existingParam.Parameter.Type + } + + var valueString string + // Check if data has empty key marker (omitted key case) + if value, hasEmptyKey := data[""]; hasEmptyKey && len(data) == 1 { + // Store value as plain string (no JSON conversion) + valueString = fmt.Sprintf("%v", value) } else { - // If parameter doesn't exist or we can't determine type, check data structure - if len(data) == 1 { - if _, ok := data["value"]; ok { - paramType = "String" - jsonData = []byte(fmt.Sprintf("%v", data["value"])) - } + // Convert data map to JSON string + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal secret data: %w", err) } + valueString = string(jsonData) } input := &ssm.PutParameterInput{ Name: aws.String(paramName), - Value: aws.String(string(jsonData)), + Value: aws.String(valueString), Type: aws.String(paramType), Overwrite: aws.Bool(true), // Overwrite for updates } diff --git a/internal/engines/vault/vault_client.go b/internal/engines/vault/vault_client.go index ed13917..34c7f92 100644 --- a/internal/engines/vault/vault_client.go +++ b/internal/engines/vault/vault_client.go @@ -185,6 +185,13 @@ func (c *VaultClient) CreateSecret(path string, data map[string]any) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + // Handle empty key marker (defensive check - handler should already transform for Vault) + if value, hasEmptyKey := data[""]; hasEmptyKey && len(data) == 1 { + data = map[string]any{ + "value": value, + } + } + // For KV v2, we need to wrap the data secretData := map[string]any{ "data": data, @@ -203,6 +210,13 @@ func (c *VaultClient) UpdateSecret(path string, data map[string]any) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + // Handle empty key marker (defensive check - handler should already transform for Vault) + if value, hasEmptyKey := data[""]; hasEmptyKey && len(data) == 1 { + data = map[string]any{ + "value": value, + } + } + // For KV v2, we need to wrap the data secretData := map[string]any{ "data": data, diff --git a/internal/ui/forms/builder.go b/internal/ui/forms/builder.go index 96073ec..b862292 100644 --- a/internal/ui/forms/builder.go +++ b/internal/ui/forms/builder.go @@ -8,6 +8,7 @@ import ( "github.com/rivo/tview" "github.com/rvolykh/vui/internal/ui/common" + "github.com/rvolykh/vui/internal/utils" "github.com/sirupsen/logrus" ) @@ -117,15 +118,18 @@ func (fb *KeyValueFormBuilder) rebuildForm() { fb.logger.Infof("rebuildForm: Processing pair %d/%d: key='%s'", idx+1, len(keys), k) v := fb.keyValuePairs[k] + // Display key label - show "(no key)" for empty keys + displayKey := utils.Coalesce(k, "(no key)") + if fb.config.ShowDeleteKeys { fb.logger.Infof("rebuildForm: Adding delete button for key='%s'", k) // For edit mode: show as input field with delete button // Create a copy of k for the closure key := k - valueField := tview.NewTextArea().SetLabel(key).SetText(v, true) + valueField := tview.NewTextArea().SetLabel(displayKey).SetText(v, true) form.AddFormItem(valueField) - form.AddButton("Delete "+key, fb.createDeleteKeyHandler(key)) + form.AddButton("Delete "+displayKey, fb.createDeleteKeyHandler(key)) } else { displayValue := strings.ReplaceAll(v, "\n", "\\n") if len(displayValue) > 40 { @@ -134,7 +138,7 @@ func (fb *KeyValueFormBuilder) rebuildForm() { fb.logger.Infof("rebuildForm: Adding read-only display for key='%s'", k) // For create mode: show as read-only text - form.AddTextView(" • "+k, displayValue, 0, 1, false, false) + form.AddTextView(" • "+displayKey, displayValue, 0, 1, false, false) } } fb.logger.Info("rebuildForm: Finished processing existing pairs") @@ -202,16 +206,16 @@ func (fb *KeyValueFormBuilder) createAddKeyValueHandler() func() { value := valueField.GetText() fb.logger.Infof("Retrieved key='%s', value length=%d", key, len(value)) - if key == "" { - fb.logger.Warn("Key cannot be empty") - return - } - if value == "" { fb.logger.Warn("Value cannot be empty") return } + // Allow empty key when value is present - use empty string as marker + if key == "" { + fb.logger.Info("Key is empty but value is present - will use empty key marker") + } + // Add to the pairs map fb.keyValuePairs[key] = value fb.logger.Infof("Added key-value pair: %s", key) @@ -250,9 +254,10 @@ func (fb *KeyValueFormBuilder) createSaveHandler() func() { key := strings.TrimSpace(keyField.GetText()) value := valueField.GetText() - if key != "" && value != "" { + // Allow empty key when value is present + if value != "" { fb.keyValuePairs[key] = value - fb.logger.Infof("Auto-added new key-value pair before save: %s", key) + fb.logger.Infof("Auto-added new key-value pair before save: key='%s'", key) } } From eaf926990a77d496f4147fc2c05d5594d97888e3 Mon Sep 17 00:00:00 2001 From: Roman Volykh Date: Thu, 30 Oct 2025 18:39:00 +0200 Subject: [PATCH 2/2] build: Enable Go Green Tea Garbage Collector --- .goreleaser.yaml | 1 + Makefile | 1 + README.md | 2 +- go.mod | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 82a9fd3..947c1c7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -12,6 +12,7 @@ builds: - -trimpath env: - CGO_ENABLED=0 + - GOEXPERIMENT=greenteagc ldflags: - -s -w -X main.Version={{.Version}} -X main.BuildTime={{.Date}} -X main.GitCommit={{.ShortCommit}} goos: diff --git a/Makefile b/Makefile index af2857f..944dbd8 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ vet: ## Examine source code .PHONY: vet build: export CGO_ENABLED=0 +build: export GOEXPERIMENT=greenteagc build: ## Build the application @ go build \ -ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)" \ diff --git a/README.md b/README.md index 63de65c..056e94d 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ profiles: ### Build from Source Prerequisites: -- Go 1.21 or later +- Go 1.25.3 or later Steps: ```bash diff --git a/go.mod b/go.mod index 3996ffd..7177f42 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/rvolykh/vui -go 1.25.1 +go 1.25.3 require ( github.com/atotto/clipboard v0.1.4