diff --git a/packages/cmd/dynamic_secrets.go b/packages/cmd/dynamic_secrets.go index 0443e77..ed32f49 100644 --- a/packages/cmd/dynamic_secrets.go +++ b/packages/cmd/dynamic_secrets.go @@ -59,6 +59,11 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + var infisicalToken string httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { @@ -123,7 +128,15 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { util.HandleError(err, "To fetch dynamic secret root credentials details") } - visualize.PrintAllDynamicRootCredentials(dynamicSecretRootCredentials) + if outputFormat != "" { + output, err := util.FormatOutput(outputFormat, dynamicSecretRootCredentials, nil) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + visualize.PrintAllDynamicRootCredentials(dynamicSecretRootCredentials) + } Telemetry.CaptureEvent("cli-command:dynamic-secrets", posthog.NewProperties().Set("count", len(dynamicSecretRootCredentials)).Set("version", util.CLI_VERSION)) } @@ -184,6 +197,11 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + var infisicalToken string httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { @@ -272,19 +290,29 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "To lease dynamic secret") } - if plainOutput { - for key, value := range leaseCredentials { - if cred, ok := value.(string); ok { - fmt.Printf("%s=%s\n", key, cred) - } + if outputFormat != "" { + + output, err := util.FormatOutput(outputFormat, leaseCredentials, nil) + if err != nil { + util.HandleError(err, "Unable to format output") } + fmt.Print(output) } else { - fmt.Println("Dynamic Secret Leasing") - fmt.Printf("Name: %s\n", dynamicSecretRootCredential.Name) - fmt.Printf("Provider: %s\n", dynamicSecretRootCredential.Type) - fmt.Printf("Lease ID: %s\n", leaseDetails.Id) - fmt.Printf("Expire At: %s\n", leaseDetails.ExpireAt.Local().Format("02-Jan-2006 03:04:05 PM")) - visualize.PrintAllDyamicSecretLeaseCredentials(leaseCredentials) + // plain output is deprecated is replaced by output=dotenv format, but remains for backwards compatibility + if plainOutput { + for key, value := range leaseCredentials { + if cred, ok := value.(string); ok { + fmt.Printf("%s=%s\n", key, cred) + } + } + } else { + fmt.Println("Dynamic Secret Leasing") + fmt.Printf("Name: %s\n", dynamicSecretRootCredential.Name) + fmt.Printf("Provider: %s\n", dynamicSecretRootCredential.Type) + fmt.Printf("Lease ID: %s\n", leaseDetails.Id) + fmt.Printf("Expire At: %s\n", leaseDetails.ExpireAt.Local().Format("02-Jan-2006 03:04:05 PM")) + visualize.PrintAllDyamicSecretLeaseCredentials(leaseCredentials) + } } Telemetry.CaptureEvent("cli-command:dynamic-secrets lease", posthog.NewProperties().Set("type", dynamicSecretRootCredential.Type).Set("version", util.CLI_VERSION)) @@ -335,6 +363,11 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + var infisicalToken string httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { @@ -404,8 +437,18 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "To renew dynamic secret lease") } - fmt.Println("Successfully renewed dynamic secret lease") - visualize.PrintAllDynamicSecretLeases([]infisicalSdkModels.DynamicSecretLease{leaseDetails}) + if outputFormat != "" { + + output, err := util.FormatOutput(outputFormat, leaseDetails, nil) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + + fmt.Println("Successfully renewed dynamic secret lease") + visualize.PrintAllDynamicSecretLeases([]infisicalSdkModels.DynamicSecretLease{leaseDetails}) + } Telemetry.CaptureEvent("cli-command:dynamic-secrets lease renew", posthog.NewProperties().Set("version", util.CLI_VERSION)) } @@ -450,6 +493,11 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + var infisicalToken string httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { @@ -514,13 +562,21 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { EnvironmentSlug: environmentName, LeaseId: dynamicSecretLeaseId, }) - if err != nil { util.HandleError(err, "To revoke dynamic secret lease") } - fmt.Println("Successfully revoked dynamic secret lease") - visualize.PrintAllDynamicSecretLeases([]infisicalSdkModels.DynamicSecretLease{leaseDetails}) + if outputFormat != "" { + output, err := util.FormatOutput(outputFormat, leaseDetails, nil) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + + fmt.Println("Successfully revoked dynamic secret lease") + visualize.PrintAllDynamicSecretLeases([]infisicalSdkModels.DynamicSecretLease{leaseDetails}) + } Telemetry.CaptureEvent("cli-command:dynamic-secrets lease revoke", posthog.NewProperties().Set("version", util.CLI_VERSION)) } @@ -565,6 +621,11 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + var infisicalToken string httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { @@ -629,7 +690,16 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "To fetch dynamic secret leases list") } - visualize.PrintAllDynamicSecretLeases(dynamicSecretLeases) + if outputFormat != "" { + output, err := util.FormatOutput(outputFormat, dynamicSecretLeases, nil) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + visualize.PrintAllDynamicSecretLeases(dynamicSecretLeases) + } + Telemetry.CaptureEvent("cli-command:dynamic-secrets lease list", posthog.NewProperties().Set("lease-count", len(dynamicSecretLeases)).Set("version", util.CLI_VERSION)) } @@ -641,6 +711,9 @@ func init() { dynamicSecretLeaseCreateCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.") dynamicSecretLeaseCreateCmd.Flags().Bool("plain", false, "Print leased credentials without formatting, one per line") + // Add output flags + util.AddOutputFlagsToCmd(dynamicSecretLeaseCreateCmd, "The output to format the leased credentials in.") + // Kubernetes specific flags dynamicSecretLeaseCreateCmd.Flags().String("kubernetes-namespace", "", "The namespace to create the lease in. Only used for Kubernetes dynamic secrets.") @@ -650,6 +723,7 @@ func init() { dynamicSecretLeaseListCmd.Flags().String("token", "", "Fetch dynamic secret leases machine identity access token") dynamicSecretLeaseListCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") dynamicSecretLeaseListCmd.Flags().String("project-slug", "", "Manually set the project-slug to list leases from") + util.AddOutputFlagsToCmd(dynamicSecretLeaseListCmd, "The output to format the dynamic secret leases in.") dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseListCmd) dynamicSecretLeaseRenewCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") @@ -657,12 +731,14 @@ func init() { dynamicSecretLeaseRenewCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") dynamicSecretLeaseRenewCmd.Flags().String("project-slug", "", "Manually set the project-slug to renew lease in") dynamicSecretLeaseRenewCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.") + util.AddOutputFlagsToCmd(dynamicSecretLeaseRenewCmd, "The output to format the dynamic secret lease renewal in.") dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseRenewCmd) dynamicSecretLeaseRevokeCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") dynamicSecretLeaseRevokeCmd.Flags().String("token", "", "Delete dynamic secrets using machine identity access token") dynamicSecretLeaseRevokeCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") dynamicSecretLeaseRevokeCmd.Flags().String("project-slug", "", "Manually set the project-slug to revoke lease from") + util.AddOutputFlagsToCmd(dynamicSecretLeaseRevokeCmd, "The output to format the dynamic secret lease revocation in.") dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseRevokeCmd) dynamicSecretCmd.AddCommand(dynamicSecretLeaseCmd) @@ -672,5 +748,6 @@ func init() { dynamicSecretCmd.Flags().String("project-slug", "", "Manually set the project-slug to fetch dynamic-secret from") dynamicSecretCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") dynamicSecretCmd.Flags().String("path", "/", "get dynamic secret within a folder path") + util.AddOutputFlagsToCmd(dynamicSecretCmd, "The output to format the dynamic secrets in.") rootCmd.AddCommand(dynamicSecretCmd) } diff --git a/packages/cmd/folder.go b/packages/cmd/folder.go index b596521..809f84c 100644 --- a/packages/cmd/folder.go +++ b/packages/cmd/folder.go @@ -46,6 +46,10 @@ var getCmd = &cobra.Command{ if err != nil { util.HandleError(err, "Unable to parse flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } request := models.GetAllFoldersParameters{ Environment: environmentName, @@ -64,7 +68,27 @@ var getCmd = &cobra.Command{ util.HandleError(err, "Unable to get folders") } - visualize.PrintAllFoldersDetails(folders, foldersPath) + if outputFormat != "" { + + var outputStructure []map[string]any + for _, folder := range folders { + outputStructure = append(outputStructure, map[string]any{ + "folderName": folder.Name, + "folderPath": foldersPath, + "folderId": folder.ID, + }) + } + + output, err := util.FormatOutput(outputFormat, outputStructure, nil) + + if err != nil { + util.HandleError(err, "Unable to format output") + } + + fmt.Print(output) + } else { + visualize.PrintAllFoldersDetails(folders, foldersPath) + } Telemetry.CaptureEvent("cli-command:folders get", posthog.NewProperties().Set("folderCount", len(folders)).Set("version", util.CLI_VERSION)) }, } @@ -101,6 +125,11 @@ var createCmd = &cobra.Command{ util.HandleError(err, "Unable to parse name flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if folderName == "" { util.HandleError(errors.New("invalid folder name, folder name cannot be empty")) } @@ -129,12 +158,27 @@ var createCmd = &cobra.Command{ params.InfisicalToken = token.Token } - _, err = util.CreateFolder(params) + folder, err := util.CreateFolder(params) if err != nil { util.HandleError(err, "Unable to create folder") } - util.PrintSuccessMessage(fmt.Sprintf("folder named `%s` created in path %s", folderName, folderPath)) + if outputFormat != "" { + + outputStructure := map[string]any{ + "folderName": folder.Name, + "folderPath": folderPath, + "folderId": folder.ID, + } + + output, err := util.FormatOutput(outputFormat, outputStructure, nil) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + util.PrintSuccessMessage(fmt.Sprintf("folder named `%s` created in path %s", folderName, folderPath)) + } Telemetry.CaptureEvent("cli-command:folders create", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, @@ -173,6 +217,11 @@ var deleteCmd = &cobra.Command{ util.HandleError(err, "Unable to parse name flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if folderName == "" { util.HandleError(errors.New("invalid folder name, folder name cannot be empty")) } @@ -202,7 +251,21 @@ var deleteCmd = &cobra.Command{ util.HandleError(err, "Unable to delete folder") } - util.PrintSuccessMessage(fmt.Sprintf("folder named `%s` deleted in path %s", folderName, folderPath)) + if outputFormat != "" { + outputStructure := map[string]any{ + "folderName": folderName, + "folderPath": folderPath, + } + + output, err := util.FormatOutput(outputFormat, outputStructure, nil) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + + util.PrintSuccessMessage(fmt.Sprintf("folder named `%s` deleted in path %s", folderName, folderPath)) + } Telemetry.CaptureEvent("cli-command:folders delete", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, diff --git a/packages/cmd/secrets.go b/packages/cmd/secrets.go index facb382..c3d4b9d 100644 --- a/packages/cmd/secrets.go +++ b/packages/cmd/secrets.go @@ -78,6 +78,11 @@ var secretsCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + request := models.GetAllSecretsParameters{ Environment: environmentName, WorkspaceId: projectId, @@ -108,12 +113,32 @@ var secretsCmd = &cobra.Command{ // Sort the secrets by key so we can create a consistent output secrets = util.SortSecretsByKeys(secrets) - if plainOutput { + if outputFormat != "" { + + var outputStructure []map[string]any for _, secret := range secrets { - fmt.Println(fmt.Sprintf("%s=%s", secret.Key, secret.Value)) + outputStructure = append(outputStructure, map[string]any{ + "secretKey": secret.Key, + "secretValue": secret.Value, + }) } + + output, err := util.FormatOutput(outputFormat, outputStructure, &util.FormatOutputOptions{ + DotEnvArrayKeyAttribute: "secretKey", + DotEnvArrayValueAttribute: "secretValue", + }) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) } else { - visualize.PrintAllSecretDetails(secrets) + if plainOutput { + for _, secret := range secrets { + fmt.Printf("%s=%s\n", secret.Key, secret.Value) + } + } else { + visualize.PrintAllSecretDetails(secrets) + } } Telemetry.CaptureEvent("cli-command:secrets", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) @@ -188,6 +213,11 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse secret type") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + processedArgs := []string{} for _, arg := range args { splitKeyValue := strings.SplitN(arg, "=", 2) @@ -224,6 +254,10 @@ var secretsSetCmd = &cobra.Command{ } secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, token, file) + + if err != nil { + util.HandleError(err, "Unable to set secrets") + } } else { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() @@ -247,10 +281,10 @@ var secretsSetCmd = &cobra.Command{ Type: "", Token: loggedInUserDetails.UserCredentials.JTWToken, }, file) - } - if err != nil { - util.HandleError(err, "Unable to set secrets") + if err != nil { + util.HandleError(err, "Unable to set secrets") + } } // Print secret operations @@ -260,8 +294,29 @@ var secretsSetCmd = &cobra.Command{ rows = append(rows, [...]string{secretOperation.SecretKey, secretOperation.SecretValue, secretOperation.SecretOperation}) } - visualize.Table(headers, rows) + if outputFormat != "" { + var outputStructure []map[string]any + for _, secretOperation := range secretOperations { + outputStructure = append(outputStructure, map[string]any{ + "secretKey": secretOperation.SecretKey, + "secretValue": secretOperation.SecretValue, + "operation": secretOperation.SecretOperation, + }) + } + + output, err := util.FormatOutput(outputFormat, outputStructure, &util.FormatOutputOptions{ + DotEnvArrayKeyAttribute: "secretKey", + DotEnvArrayValueAttribute: "secretValue", + }) + + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + visualize.Table(headers, rows) + } Telemetry.CaptureEvent("cli-command:secrets set", posthog.NewProperties().Set("version", util.CLI_VERSION)) }, } @@ -301,6 +356,11 @@ var secretsDeleteCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { util.HandleError(err, "Unable to get resty client with custom headers") @@ -348,7 +408,23 @@ var secretsDeleteCmd = &cobra.Command{ } } - fmt.Printf("secret name(s) [%v] have been deleted from your project \n", strings.Join(args, ", ")) + if outputFormat != "" { + var outputStructure []map[string]any + for _, secretName := range args { + outputStructure = append(outputStructure, map[string]any{ + "secretKey": secretName, + }) + } + output, err := util.FormatOutput(outputFormat, outputStructure, &util.FormatOutputOptions{ + DotEnvArrayKeyAttribute: "secretKey", + }) + if err != nil { + util.HandleError(err, "Unable to format output") + } + fmt.Print(output) + } else { + fmt.Printf("secret name(s) [%v] have been deleted from your project \n", strings.Join(args, ", ")) + } Telemetry.CaptureEvent("cli-command:secrets delete", posthog.NewProperties().Set("secretCount", len(args)).Set("version", util.CLI_VERSION)) }, @@ -393,6 +469,11 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse recursive flag") } + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + // deprecated, in favor of --plain showOnlyValue, err := cmd.Flags().GetBool("raw-value") if err != nil { @@ -404,6 +485,10 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + if showOnlyValue { + plainOutput = true + } + includeImports, err := cmd.Flags().GetBool("include-imports") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -449,7 +534,7 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { if value, ok := secretsMap[secretKeyFromArg]; ok { requestedSecrets = append(requestedSecrets, value) } else { - if !(plainOutput || showOnlyValue) { + if !plainOutput { requestedSecrets = append(requestedSecrets, models.SingleEnvironmentVariable{ Key: secretKeyFromArg, Type: "*not found*", @@ -459,13 +544,35 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - // showOnlyValue deprecated in favor of --plain, below only for backward compatibility - if plainOutput || showOnlyValue { + if outputFormat != "" && !plainOutput { + + var outputStructure []map[string]any for _, secret := range requestedSecrets { - fmt.Println(secret.Value) + outputStructure = append(outputStructure, map[string]any{ + "secretKey": secret.Key, + "secretValue": secret.Value, + }) } + + output, err := util.FormatOutput(outputFormat, outputStructure, &util.FormatOutputOptions{ + DotEnvArrayKeyAttribute: "secretKey", + DotEnvArrayValueAttribute: "secretValue", + }) + if err != nil { + util.HandleError(err, "Unable to format output") + } + + fmt.Print(output) } else { - visualize.PrintAllSecretDetails(requestedSecrets) + + // showOnlyValue deprecated in favor of --plain, below only for backward compatibility + if plainOutput { + for _, secret := range requestedSecrets { + fmt.Println(secret.Value) + } + } else { + visualize.PrintAllSecretDetails(requestedSecrets) + } } Telemetry.CaptureEvent("cli-command:secrets get", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) @@ -703,6 +810,7 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod } func init() { + // not doing this one secretsGenerateExampleEnvCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") secretsGenerateExampleEnvCmd.Flags().String("projectId", "", "manually set the projectId when using machine identity based auth") secretsGenerateExampleEnvCmd.Flags().String("path", "/", "Fetch secrets from within a folder path") @@ -713,23 +821,27 @@ func init() { secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") secretsGetCmd.Flags().Bool("plain", false, "print values without formatting, one per line") secretsGetCmd.Flags().Bool("raw-value", false, "deprecated. Returns only the value of secret, only works with one secret. Use --plain instead") + secretsGetCmd.Flags().MarkHidden("raw-value") // hide from --help output secretsGetCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") secretsGetCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") secretsGetCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") secretsGetCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") + util.AddOutputFlagsToCmd(secretsGetCmd, "The output to format the secrets in.") secretsCmd.AddCommand(secretsGetCmd) - secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") + secretsCmd.AddCommand(secretsSetCmd) secretsSetCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") secretsSetCmd.Flags().String("projectId", "", "manually set the project ID to for setting secrets when using machine identity based auth") secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") secretsSetCmd.Flags().String("type", util.SECRET_TYPE_SHARED, "the type of secret to create: personal or shared") secretsSetCmd.Flags().String("file", "", "Load secrets from the specified file. File format: .env or YAML (comments: # or //). This option is mutually exclusive with command-line secrets arguments.") + util.AddOutputFlagsToCmd(secretsSetCmd, "The output to format the secrets in.") secretsDeleteCmd.Flags().String("type", "personal", "the type of secret to delete: personal or shared (default: personal)") secretsDeleteCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") secretsDeleteCmd.Flags().String("projectId", "", "manually set the projectId to delete secrets from when using machine identity based auth") secretsDeleteCmd.Flags().String("path", "/", "get secrets within a folder path") + util.AddOutputFlagsToCmd(secretsDeleteCmd, "The output to format the secrets in.") secretsCmd.AddCommand(secretsDeleteCmd) // *** Folders sub command *** @@ -739,6 +851,7 @@ func init() { getCmd.Flags().StringP("path", "p", "/", "The path from where folders should be fetched from") getCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") getCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from when using machine identity based auth") + util.AddOutputFlagsToCmd(getCmd, "The output to format the folders in.") folderCmd.AddCommand(getCmd) // Add createCmd flags here @@ -746,6 +859,7 @@ func init() { createCmd.Flags().StringP("name", "n", "", "Name of the folder to be created in selected `--path`") createCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") createCmd.Flags().String("projectId", "", "manually set the project ID for creating folders in when using machine identity based auth") + util.AddOutputFlagsToCmd(createCmd, "The output to format the folders in.") folderCmd.AddCommand(createCmd) // Add deleteCmd flags here @@ -753,6 +867,7 @@ func init() { deleteCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") deleteCmd.Flags().String("projectId", "", "manually set the projectId to delete folders when using machine identity based auth") deleteCmd.Flags().StringP("name", "n", "", "Name of the folder to be deleted within selected `--path`") + util.AddOutputFlagsToCmd(deleteCmd, "The output to format the folders in.") folderCmd.AddCommand(deleteCmd) secretsCmd.AddCommand(folderCmd) @@ -767,6 +882,8 @@ func init() { secretsCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") secretsCmd.PersistentFlags().StringP("tags", "t", "", "filter secrets by tag slugs") secretsCmd.Flags().String("path", "/", "get secrets within a folder path") - secretsCmd.Flags().Bool("plain", false, "print values without formatting, one per line") + secretsCmd.Flags().Bool("plain", false, "print values without formatting, one per line (deprecated, use --output instead)") + secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") + util.AddOutputFlagsToCmd(secretsCmd, "The output to format the secrets in.") rootCmd.AddCommand(secretsCmd) } diff --git a/packages/util/common.go b/packages/util/common.go index 07618ae..125db9e 100644 --- a/packages/util/common.go +++ b/packages/util/common.go @@ -1,14 +1,28 @@ package util import ( + "encoding/json" + "errors" "fmt" "net/http" "os" + "slices" "strings" "unicode" "github.com/Infisical/infisical-merge/packages/config" + "github.com/Infisical/infisical-merge/packages/util/levenshtein" "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" +) + +var ( + OUTPUT_FORMAT_YAML = "yaml" + OUTPUT_FORMAT_JSON = "json" + OUTPUT_FORMAT_DOTENV = "dotenv" + + SUPPORTED_OUTPUT_FORMATS = []string{OUTPUT_FORMAT_YAML, OUTPUT_FORMAT_JSON, OUTPUT_FORMAT_DOTENV} ) func GetHomeDir() (string, error) { @@ -115,3 +129,149 @@ func GetInfisicalCustomHeadersMap() (map[string]string, error) { return headers, nil } + +func findClosestMatch(input string, options []string) string { + minDistance := len(input) + 10 + closestMatch := "" + + for _, option := range options { + distance := levenshtein.ComputeDistance( + strings.ToLower(input), + strings.ToLower(option), + ) + + if distance < minDistance && distance <= 2 { + minDistance = distance + closestMatch = option + } + } + + return closestMatch +} + +type FormatOutputOptions struct { + DotEnvArrayKeyAttribute string + DotEnvArrayValueAttribute string +} + +func FormatOutput[T any](outputFormat string, input T, options *FormatOutputOptions) (string, error) { + + if !slices.Contains(SUPPORTED_OUTPUT_FORMATS, outputFormat) { + closestMatch := findClosestMatch(outputFormat, SUPPORTED_OUTPUT_FORMATS) + + errorMessage := fmt.Sprintf("invalid output format: %s. Supported formats are %s.", outputFormat, strings.Join(SUPPORTED_OUTPUT_FORMATS, ", ")) + + if closestMatch != "" { + errorMessage += fmt.Sprintf("\nDid you mean '%s'?", closestMatch) + } + + return "", errors.New(errorMessage) + } + + formatAsJson := func(input T) (string, error) { + // can directly marshal to json without worrying about formatting + + jsonBytes, err := json.Marshal(input) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + formatAsDotEnv := func(input T) (string, error) { + jsonBytes, err := json.Marshal(input) + if err != nil { + return "", err + } + + // try to unmarshal as array first + var dataArray []map[string]any + if err := json.Unmarshal(jsonBytes, &dataArray); err == nil { + // if it succeeds we are dealing with an array of objects + var dotenv string + var lastIndex int + for i, item := range dataArray { + + if options != nil && options.DotEnvArrayKeyAttribute != "" && options.DotEnvArrayValueAttribute != "" { + dotenv += fmt.Sprintf("%s=%s\n", item[options.DotEnvArrayKeyAttribute], item[options.DotEnvArrayValueAttribute]) + continue + } + + for key, value := range item { + if lastIndex != i { + dotenv += "\n" + lastIndex = i + } + dotenv += fmt.Sprintf("%s=%v\n", key, value) + } + } + return dotenv, nil + } + + // try to marshal to a string map directly + var dataMap map[string]any + if err := json.Unmarshal(jsonBytes, &dataMap); err != nil { + return "", fmt.Errorf("input must be an object or array of objects") + } + + var dotenv string + for key, value := range dataMap { + dotenv += fmt.Sprintf("%s=%v\n", key, value) + } + return dotenv, nil + } + + formatAsYaml := func(input T) (string, error) { + // special handling is needed in order to respect the json tags attributed to the struct fields + + // check if its a map[string]any, if it is we can print it directly as yaml without worrying about formatting + if _, ok := any(input).(map[string]any); ok { + yamlBytes, err := yaml.Marshal(input) + if err != nil { + return "", err + } + return string(yamlBytes), nil + } + + // convert to json first (forces it to use json tags (if any) attributed to the struct fields) + jsonBytes, err := json.Marshal(input) + if err != nil { + return "", err + } + + // unmarshal to map[string]any to preserve JSON field names (in case of nested structs) + var data any + if err := json.Unmarshal(jsonBytes, &data); err != nil { + return "", err + } + + // marshal to YAML (will use the JSON field names) + yamlBytes, err := yaml.Marshal(data) + if err != nil { + return "", err + } + return string(yamlBytes), nil + } + + switch outputFormat { + case OUTPUT_FORMAT_YAML: + return formatAsYaml(input) + case OUTPUT_FORMAT_JSON: + return formatAsJson(input) + case OUTPUT_FORMAT_DOTENV: + return formatAsDotEnv(input) + default: + return "", fmt.Errorf("invalid output format: %s. Supported formats are %s", outputFormat, strings.Join(SUPPORTED_OUTPUT_FORMATS, ", ")) + } +} + +func AddOutputFlagsToCmd(cmd *cobra.Command, outputDescription string) { + + supportedFormats := strings.Join(SUPPORTED_OUTPUT_FORMATS, ", ") + + if outputDescription != "" && outputDescription[len(outputDescription)-1] == '.' { + outputDescription = outputDescription[:len(outputDescription)-1] + } + + cmd.Flags().StringP("output", "o", "", fmt.Sprintf("%s. Supported formats are %s", outputDescription, supportedFormats)) +} diff --git a/packages/util/levenshtein/levenshtein.go b/packages/util/levenshtein/levenshtein.go new file mode 100644 index 0000000..475cd48 --- /dev/null +++ b/packages/util/levenshtein/levenshtein.go @@ -0,0 +1,113 @@ +package levenshtein + +// Package levenshtein is a Go implementation to calculate Levenshtein Distance. +// +// Implementation taken from +// https://gist.github.com/andrei-m/982927#gistcomment-1931258 + +// Credits: https://github.com/agnivade/levenshtein + +import "unicode/utf8" + +// minLengthThreshold is the length of the string beyond which +// an allocation will be made. Strings smaller than this will be +// zero alloc. +const minLengthThreshold = 32 + +// ComputeDistance computes the levenshtein distance between the two +// strings passed as an argument. The return value is the levenshtein distance +// +// Works on runes (Unicode code points) but does not normalize +// the input strings. See https://blog.golang.org/normalization +// and the golang.org/x/text/unicode/norm package. +func ComputeDistance(a, b string) int { + if len(a) == 0 { + return utf8.RuneCountInString(b) + } + + if len(b) == 0 { + return utf8.RuneCountInString(a) + } + + if a == b { + return 0 + } + + // We need to convert to []rune if the strings are non-ASCII. + // This could be avoided by using utf8.RuneCountInString + // and then doing some juggling with rune indices, + // but leads to far more bounds checks. It is a reasonable trade-off. + s1 := []rune(a) + s2 := []rune(b) + + // swap to save some memory O(min(a,b)) instead of O(a) + if len(s1) > len(s2) { + s1, s2 = s2, s1 + } + + // remove trailing identical runes. + s1, s2 = trimLongestCommonSuffix(s1, s2) + + // Remove leading identical runes. + s1, s2 = trimLongestCommonPrefix(s1, s2) + + lenS1 := len(s1) + lenS2 := len(s2) + + // Init the row. + var x []uint16 + if lenS1+1 > minLengthThreshold { + x = make([]uint16, lenS1+1) + } else { + // We make a small optimization here for small strings. + // Because a slice of constant length is effectively an array, + // it does not allocate. So we can re-slice it to the right length + // as long as it is below a desired threshold. + x = make([]uint16, minLengthThreshold) + x = x[:lenS1+1] + } + + // we start from 1 because index 0 is already 0. + for i := 1; i < len(x); i++ { + x[i] = uint16(i) + } + + // hoist bounds checks out of the loops + _ = x[lenS1] + y := x[1:] + y = y[:lenS1] + // fill in the rest + for i := 0; i < lenS2; i++ { + prev := uint16(i + 1) + for j := 0; j < lenS1; j++ { + current := x[j] // match + if s2[i] != s1[j] { + current = min(x[j], prev, y[j]) + 1 + } + x[j] = prev + prev = current + } + x[lenS1] = prev + } + return int(x[lenS1]) +} + +func trimLongestCommonSuffix(a, b []rune) ([]rune, []rune) { + m := min(len(a), len(b)) + a2 := a[len(a)-m:] + b2 := b[len(b)-m:] + i := len(a2) + b2 = b2[:i] // hoist bounds checks out of the loop + for ; i > 0 && a2[i-1] == b2[i-1]; i-- { + // deliberately empty body + } + return a[:len(a)-len(a2)+i], b[:len(b)-len(b2)+i] +} + +func trimLongestCommonPrefix(a, b []rune) ([]rune, []rune) { + var i int + for m := min(len(a), len(b)); i < m && a[i] == b[i]; i++ { + // deliberately empty body + } + return a[i:], b[i:] +}