From 6c992f75287bd0c9c50698f29a650d09343c8765 Mon Sep 17 00:00:00 2001 From: Jose Velazquez Date: Tue, 26 May 2026 23:36:37 -0400 Subject: [PATCH 1/2] feat(cmd): add dotnet appsettings.json export formats --- packages/cmd/export.go | 96 +++++++++++++++++++++++++++++- packages/cmd/export_test.go | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 3 deletions(-) diff --git a/packages/cmd/export.go b/packages/cmd/export.go index a23dd430..b8043cc5 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -24,6 +24,9 @@ const ( FormatCSV string = "csv" FormatYaml string = "yaml" FormatDotEnvExport string = "dotenv-export" + FormatAppSettings string = "appsettings" + FormatDotnetJson string = "dotnet-json" + FormatAspnetJson string = "aspnet-json" ) // exportCmd represents the export command @@ -231,6 +234,8 @@ func getDefaultFilename(format string) string { switch strings.ToLower(format) { case FormatJson: return "secrets.json" + case FormatAppSettings, FormatDotnetJson, FormatAspnetJson: + return "appsettings.json" case FormatCSV: return "secrets.csv" case FormatYaml: @@ -247,7 +252,7 @@ func getDefaultFilename(format string) string { // getDefaultExtension returns the default file extension based on the format func getDefaultExtension(format string) string { switch strings.ToLower(format) { - case FormatJson: + case FormatJson, FormatAppSettings, FormatDotnetJson, FormatAspnetJson: return ".json" case FormatCSV: return ".csv" @@ -266,7 +271,7 @@ func init() { RootCmd.AddCommand(exportCmd) exportCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") - exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") + exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv, yaml, appsettings)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets") exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") @@ -290,8 +295,10 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, return formatAsCSV(envs), nil case FormatYaml: return formatAsYaml(envs) + case FormatAppSettings, FormatDotnetJson, FormatAspnetJson: + return formatAsAppSettingsJson(envs), nil default: - return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport}) + return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport, FormatAppSettings, FormatDotnetJson, FormatAspnetJson}) } } @@ -350,6 +357,89 @@ func formatAsJson(envs []models.SingleEnvironmentVariable) string { return string(json) } +// Attempts to parse a string value as JSON (object, array, number, boolean, null) +// falls back to the raw string on error. +func parseSecretValue(val string) interface{} { + trimmed := strings.TrimSpace(val) + var parsed interface{} + err := json.Unmarshal([]byte(trimmed), &parsed) + if err != nil { + return val + } + if parsed == nil && trimmed != "null" { + return val + } + return parsed +} + +// Recursively merges two maps +func mergeMaps(dest, src map[string]interface{}) { + for k, v := range src { + destVal, exists := dest[k] + if !exists { + dest[k] = v + continue + } + + destMap, destIsMap := destVal.(map[string]interface{}) + srcMap, srcIsMap := v.(map[string]interface{}) + if destIsMap && srcIsMap { + mergeMaps(destMap, srcMap) + } else { + dest[k] = v + } + } +} + +// Formats environment variables into a nested JSON object structure compatible with ASP.NET Core appsettings.json +func formatAsAppSettingsJson(envs []models.SingleEnvironmentVariable) string { + result := make(map[string]interface{}) + + for _, env := range envs { + val := parseSecretValue(env.Value) + + normalizedKey := strings.ReplaceAll(env.Key, "__", ":") + parts := strings.Split(normalizedKey, ":") + + current := result + for i, part := range parts { + if i == len(parts)-1 { + existingVal, exists := current[part] + existingMap, existingIsMap := existingVal.(map[string]interface{}) + newMap, newIsMap := val.(map[string]interface{}) + if exists && existingIsMap && newIsMap { + mergeMaps(existingMap, newMap) + } else { + current[part] = val + } + } else { + next, exists := current[part] + if !exists { + nextMap := make(map[string]interface{}) + current[part] = nextMap + current = nextMap + } else { + nextMap, ok := next.(map[string]interface{}) + if !ok { + nextMap = make(map[string]interface{}) + current[part] = nextMap + current = nextMap + } else { + current = nextMap + } + } + } + } + } + + jsonBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + log.Err(err).Msgf("Unable to marshal environment variables to appsettings JSON") + return "" + } + return string(jsonBytes) +} + func escapeNewLinesIfRequired(env models.SingleEnvironmentVariable) string { if env.IsMultilineEncodingEnabled() && strings.ContainsRune(env.Value, '\n') { return strings.ReplaceAll(env.Value, "\n", "\\n") diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 1be0a7ed..514b3307 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -77,3 +77,116 @@ func TestFormatAsYaml(t *testing.T) { }) } } + +func TestFormatAsAppSettingsJson(t *testing.T) { + tests := []struct { + name string + input []models.SingleEnvironmentVariable + expected string + }{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + expected: "{}\n", + }, + { + name: "Flat primitive types parsing", + input: []models.SingleEnvironmentVariable{ + {Key: "PORT", Value: "8080"}, + {Key: "DEBUG", Value: "true"}, + {Key: "NAME", Value: "MyApplication"}, + {Key: "NULL_VAL", Value: "null"}, + }, + expected: `{ + "DEBUG": true, + "NAME": "MyApplication", + "NULL_VAL": null, + "PORT": 8080 +}`, + }, + { + name: "Nested keys with colons", + input: []models.SingleEnvironmentVariable{ + {Key: "AWS:Region", Value: "us-east-1"}, + {Key: "ConnectionStrings:Default", Value: "Server=myServerAddress;Database=myDataBase;"}, + }, + expected: `{ + "AWS": { + "Region": "us-east-1" + }, + "ConnectionStrings": { + "Default": "Server=myServerAddress;Database=myDataBase;" + } +}`, + }, + { + name: "Nested keys with double underscores", + input: []models.SingleEnvironmentVariable{ + {Key: "AWS__Region", Value: "us-east-2"}, + {Key: "ConnectionStrings__Default", Value: "Server=otherServer;"}, + }, + expected: `{ + "AWS": { + "Region": "us-east-2" + }, + "ConnectionStrings": { + "Default": "Server=otherServer;" + } +}`, + }, + { + name: "Values as JSON objects/arrays", + input: []models.SingleEnvironmentVariable{ + {Key: "AWS", Value: `{"Region":"us-west-2","Version":"v2"}`}, + {Key: "TAGS", Value: `["production","web"]`}, + }, + expected: `{ + "AWS": { + "Region": "us-west-2", + "Version": "v2" + }, + "TAGS": [ + "production", + "web" + ] +}`, + }, + { + name: "Deep nesting and merging objects", + input: []models.SingleEnvironmentVariable{ + {Key: "AWS:Credentials:AccessKey", Value: "AKIAIOSFODNN7EXAMPLE"}, + {Key: "AWS:Credentials:SecretKey", Value: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}, + {Key: "AWS:Region", Value: "us-east-1"}, + }, + expected: `{ + "AWS": { + "Credentials": { + "AccessKey": "AKIAIOSFODNN7EXAMPLE", + "SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + }, + "Region": "us-east-1" + } +}`, + }, + { + name: "Merging parsed JSON objects and explicit nested keys in any order", + input: []models.SingleEnvironmentVariable{ + {Key: "AWS", Value: `{"Region":"us-east-1"}`}, + {Key: "AWS:SecretKey", Value: "abc"}, + }, + expected: `{ + "AWS": { + "Region": "us-east-1", + "SecretKey": "abc" + } +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatAsAppSettingsJson(tt.input) + assert.JSONEq(t, tt.expected, result) + }) + } +} From de652ff793f1069fef8278f4e021970a9d1a65c1 Mon Sep 17 00:00:00 2001 From: Jose Velazquez Date: Tue, 26 May 2026 23:40:34 -0400 Subject: [PATCH 2/2] refactor(cmd): remove redundant dotnet-json and aspnet-json aliases --- packages/cmd/export.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/cmd/export.go b/packages/cmd/export.go index b8043cc5..99b43e88 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -25,8 +25,6 @@ const ( FormatYaml string = "yaml" FormatDotEnvExport string = "dotenv-export" FormatAppSettings string = "appsettings" - FormatDotnetJson string = "dotnet-json" - FormatAspnetJson string = "aspnet-json" ) // exportCmd represents the export command @@ -234,7 +232,7 @@ func getDefaultFilename(format string) string { switch strings.ToLower(format) { case FormatJson: return "secrets.json" - case FormatAppSettings, FormatDotnetJson, FormatAspnetJson: + case FormatAppSettings: return "appsettings.json" case FormatCSV: return "secrets.csv" @@ -252,7 +250,7 @@ func getDefaultFilename(format string) string { // getDefaultExtension returns the default file extension based on the format func getDefaultExtension(format string) string { switch strings.ToLower(format) { - case FormatJson, FormatAppSettings, FormatDotnetJson, FormatAspnetJson: + case FormatJson, FormatAppSettings: return ".json" case FormatCSV: return ".csv" @@ -295,10 +293,10 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, return formatAsCSV(envs), nil case FormatYaml: return formatAsYaml(envs) - case FormatAppSettings, FormatDotnetJson, FormatAspnetJson: + case FormatAppSettings: return formatAsAppSettingsJson(envs), nil default: - return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport, FormatAppSettings, FormatDotnetJson, FormatAspnetJson}) + return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport, FormatAppSettings}) } }