diff --git a/cmd/cloud.go b/cmd/cloud.go index a6e21d1a..0f83ce8a 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -25,6 +25,7 @@ import ( "github.com/agentuity/go-common/logger" cstr "github.com/agentuity/go-common/string" "github.com/agentuity/go-common/tui" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/spf13/viper" "k8s.io/apimachinery/pkg/api/resource" @@ -121,6 +122,63 @@ func ShowNewProjectImport(ctx context.Context, logger logger.Logger, cmd *cobra. tui.ShowSuccess("Project imported successfully") } +var envTemplateFileNames = []string{".env.example", ".env.template"} + +func readPossibleEnvTemplateFiles(baseDir string) map[string][]env.EnvLineComment { + var results map[string][]env.EnvLineComment + keys := make(map[string]bool) + for _, file := range envTemplateFileNames { + filename := filepath.Join(baseDir, file) + if !util.Exists(filename) { + continue + } + efc, err := env.ParseEnvFileWithComments(filename) + if err == nil { + if results == nil { + results = make(map[string][]env.EnvLineComment) + } + for _, ev := range efc { + if _, ok := keys[ev.Key]; !ok { + if isAgentuityEnv.MatchString(ev.Key) { + continue + } + keys[ev.Key] = true + results[file] = append(results[file], ev) + } + } + } + } + return results +} + +func appendToEnvFile(envfile string, envs []env.EnvLineComment) ([]env.EnvLineComment, error) { + le, err := env.ParseEnvFileWithComments(envfile) + if err != nil { + return nil, err + } + var buf strings.Builder + for _, ev := range le { + if ev.Comment != "" { + buf.WriteString(fmt.Sprintf("# %s\n", ev.Comment)) + } + buf.WriteString(fmt.Sprintf("%s=%s\n", ev.Key, ev.Raw)) + } + for _, ev := range envs { + if ev.Comment != "" { + buf.WriteString(fmt.Sprintf("# %s\n", ev.Comment)) + } + buf.WriteString(fmt.Sprintf("%s=%s\n", ev.Key, ev.Raw)) + le = append(le, ev) + } + if err := os.WriteFile(envfile, []byte(buf.String()), 0644); err != nil { + return nil, err + } + return le, nil +} + +var border = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(1).BorderForeground(lipgloss.AdaptiveColor{Light: "#999999", Dark: "#999999"}) +var redDiff = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#990000", Dark: "#EE0000"}) + var cloudDeployCmd = &cobra.Command{ Use: "deploy", Short: "Deploy project to the cloud", @@ -193,7 +251,7 @@ Examples: } var err error - var le []env.EnvLine + var le []env.EnvLineComment var projectExists bool var action func() @@ -228,13 +286,80 @@ Examples: envfilename := filepath.Join(dir, ".env") if tui.HasTTY && util.Exists(envfilename) { - le, err = env.ParseEnvFile(envfilename) + // attempt to see if we have any template files + templateEnvs := readPossibleEnvTemplateFiles(dir) + + le, err = env.ParseEnvFileWithComments(envfilename) if err != nil { errsystem.New(errsystem.ErrParseEnvironmentFile, err, errsystem.WithContextMessage("Error parsing .env file")).ShowErrorAndExit() } envFile = &deployer.EnvFile{Filepath: envfilename, Env: le} + if len(templateEnvs) > 0 { + kvmap := make(map[string]env.EnvLineComment) + for _, ev := range le { + if isAgentuityEnv.MatchString(ev.Key) { + continue + } + kvmap[ev.Key] = ev + } + var osenv map[string]string + var addtoenvfile []env.EnvLineComment + // look to see if we have any template environment variables that are not in the .env file + for filename, evs := range templateEnvs { + for _, ev := range evs { + if _, ok := kvmap[ev.Key]; !ok { + isSecret := looksLikeSecret.MatchString(ev.Key) + if !isSecret && descriptionLookingLikeASecret(ev.Comment) { + isSecret = true + } + _ = filename + var content string + var para []string + para = append(para, tui.Warning("Missing Environment Variable\n")) + para = append(para, fmt.Sprintf("The variable %s was found in %s but not in your %s file:\n", tui.Bold(ev.Key), tui.Bold(filename), tui.Bold(".env"))) + if ev.Comment != "" { + para = append(para, tui.Muted(fmt.Sprintf("# %s", ev.Comment))) + } + if isSecret { + para = append(para, redDiff.Render(fmt.Sprintf("+ %s=%s\n", ev.Key, cstr.Mask(ev.Val)))) + } else { + para = append(para, redDiff.Render(fmt.Sprintf("+ %s=%s\n", ev.Key, ev.Val))) + } + content = lipgloss.JoinVertical(lipgloss.Left, para...) + fmt.Println(border.Render(content)) + if !tui.Ask(logger, "Would you like to add it to your .env file?", true) { + fmt.Println() + tui.ShowWarning("cancelled") + continue + } + if osenv == nil { + osenv = loadOSEnv() + } + val := promptForEnv(logger, ev.Key, isSecret, nil, osenv, ev.Val, ev.Comment) + addtoenvfile = append(addtoenvfile, env.EnvLineComment{ + EnvLine: env.EnvLine{ + Key: ev.Key, + Val: val, + Raw: val, + }, + Comment: ev.Comment, + }) + } + } + } + if len(addtoenvfile) > 0 { + le, err = appendToEnvFile(envfilename, addtoenvfile) + if err != nil { + errsystem.New(errsystem.ErrParseEnvironmentFile, err, + errsystem.WithContextMessage("Error parsing .env file")).ShowErrorAndExit() + } + tui.ShowSuccess("added %s to your .env file", util.Pluralize(len(addtoenvfile), "environment variable", "environment variables")) + fmt.Println() + } + } + var foundkeys []string for _, ev := range le { if isAgentuityEnv.MatchString(ev.Key) { @@ -250,15 +375,25 @@ Examples: } if len(foundkeys) > 0 { var title string + var suffix string switch { case len(foundkeys) < 3 && len(foundkeys) > 1: - title = fmt.Sprintf("The environment variables %s from .env are not been set in the project. Would you like to add it?", strings.Join(foundkeys, ", ")) + suffix = "it" + var colorized []string + for _, key := range foundkeys { + colorized = append(colorized, tui.Bold(key)) + } + title = fmt.Sprintf("The environment variables %s from %s are not been set in the project.", strings.Join(colorized, ", "), tui.Bold(".env")) case len(foundkeys) == 1: - title = fmt.Sprintf("The environment variable %s from .env has not been set in the project. Would you like to add it?", foundkeys[0]) + suffix = "it" + title = fmt.Sprintf("The environment variable %s from %s has not been set in the project.", tui.Bold(foundkeys[0]), tui.Bold(".env")) default: - title = fmt.Sprintf("There are %d environment variables from .env that are not set in the project. Would you like to add them?", len(foundkeys)) + suffix = "them" + title = fmt.Sprintf("There are %d environment variables from %s that are not set in the project.", len(foundkeys), tui.Bold(".env")) } - if !tui.Ask(logger, title, true) { + fmt.Println(title) + if !tui.Ask(logger, "Would you like to set "+suffix+"?", true) { + fmt.Println() tui.ShowWarning("cancelled") return } @@ -271,6 +406,8 @@ Examples: errsystem.New(errsystem.ErrEnvironmentVariablesNotSet, err, errsystem.WithContextMessage("Failed to set project environment variables")).ShowErrorAndExit() } + fmt.Println() + fmt.Println() projectData = pd // overwrite with the new version switch { case len(envs) > 0 && len(secrets) > 0: @@ -284,6 +421,7 @@ Examples: case len(secrets) > 1: tui.ShowSuccess("Secrets added") } + fmt.Println() } } diff --git a/cmd/dev.go b/cmd/dev.go index 220766ba..430c74fb 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -6,7 +6,6 @@ import ( "io" "os" "os/signal" - "runtime" "strings" "syscall" "time" @@ -44,12 +43,7 @@ Examples: logLevel := env.LogLevel(cmd) apiUrl, appUrl, transportUrl := util.GetURLs(log) - signals := []os.Signal{os.Interrupt, syscall.SIGINT} - if runtime.GOOS != "windows" { - signals = append(signals, syscall.SIGTERM) - } - - ctx, cancel := signal.NotifyContext(context.Background(), signals...) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer cancel() apiKey, userId := util.EnsureLoggedIn(ctx, log, cmd) diff --git a/cmd/env.go b/cmd/env.go index e6ee3698..a0077ab1 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -41,14 +41,31 @@ var ( isAgentuityEnv = regexp.MustCompile(`(?i)AGENTUITY_`) ) -func loadEnvFile(le []env.EnvLine, forceSecret bool) (map[string]string, map[string]string) { +func descriptionLookingLikeASecret(description string) bool { + if description == "" { + return false + } + val := strings.ToLower(description) + if strings.Contains(val, "secret") { + return true + } + if strings.Contains(val, "password") { + return true + } + if strings.Contains(val, "key") { + return true + } + return false +} + +func loadEnvFile(le []env.EnvLineComment, forceSecret bool) (map[string]string, map[string]string) { envs := make(map[string]string) secrets := make(map[string]string) for _, ev := range le { if isAgentuityEnv.MatchString(ev.Key) { continue } - if looksLikeSecret.MatchString(ev.Key) || forceSecret { + if looksLikeSecret.MatchString(ev.Key) || forceSecret || descriptionLookingLikeASecret(ev.Comment) { secrets[ev.Key] = ev.Val } else { envs[ev.Key] = ev.Val @@ -57,25 +74,32 @@ func loadEnvFile(le []env.EnvLine, forceSecret bool) (map[string]string, map[str return envs, secrets } -func promptForEnv(logger logger.Logger, key string, isSecret bool, localenv map[string]string, osenv map[string]string) string { +func promptForEnv(logger logger.Logger, key string, isSecret bool, localenv map[string]string, osenv map[string]string, defaultValue string, placeholder string) string { prompt := "Enter the value for " + key var help string - var defaultValue string var value string if isSecret { prompt = "Enter the secret value for " + key if val, ok := localenv[key]; ok { help = "Press enter to set as " + maxString(cstr.Mask(val), 30) + " from your .env file" - defaultValue = val + if defaultValue == "" { + defaultValue = val + } } else if val, ok := osenv[key]; ok { help = "Press enter to set as " + maxString(cstr.Mask(val), 30) + " from your environment" - defaultValue = val + if defaultValue == "" { + defaultValue = val + } } else { help = "Your input will be masked" } value = tui.Password(logger, prompt, help) } else { - value = tui.Input(logger, prompt, help) + if placeholder == "" { + value = tui.InputWithPlaceholder(logger, prompt, placeholder, defaultValue) + } else { + value = tui.InputWithPlaceholder(logger, prompt, help, defaultValue) + } } if value == "" && defaultValue != "" { @@ -141,7 +165,7 @@ Examples: setFromFile, err := cmd.Flags().GetString("file") if setFromFile != "" { if sys.Exists(setFromFile) { - le, _ := env.ParseEnvFile(setFromFile) + le, _ := env.ParseEnvFileWithComments(setFromFile) envs, secrets = loadEnvFile(le, forceSecret) if len(envs) > 0 || len(secrets) > 0 { hasSetFromFile = true @@ -227,7 +251,7 @@ Examples: } } if value == "" { - value = promptForEnv(logger, key, isSecret, localenv, osenv) + value = promptForEnv(logger, key, isSecret, localenv, osenv, "", "") } } if key != "" && value != "" { diff --git a/go.mod b/go.mod index 28f5398d..fd335441 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.2 require ( github.com/Masterminds/semver v1.5.0 - github.com/agentuity/go-common v1.0.59 + github.com/agentuity/go-common v1.0.60 github.com/agentuity/mcp-golang/v2 v2.0.2 github.com/bep/debounce v1.2.1 github.com/bmatcuk/doublestar/v4 v4.8.1 diff --git a/go.sum b/go.sum index efc4f535..193d1076 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= -github.com/agentuity/go-common v1.0.59 h1:WOR35IDV6X7qSBr+E7ztF1PygENO305/nEKd+rDAX2A= -github.com/agentuity/go-common v1.0.59/go.mod h1:cy1EPYpZUkp3JSMgTb+Sa3sLnS7vQQupj/RwO4An6L4= +github.com/agentuity/go-common v1.0.60 h1:r9uLZrYnNasnVxsTGju7ktP9+A5D3j13ald8H3Z7AMQ= +github.com/agentuity/go-common v1.0.60/go.mod h1:cy1EPYpZUkp3JSMgTb+Sa3sLnS7vQQupj/RwO4An6L4= github.com/agentuity/mcp-golang/v2 v2.0.2 h1:wZqS/aHWZsQoU/nd1E1/iMsVY2dywWT9+PFlf+3YJxo= github.com/agentuity/mcp-golang/v2 v2.0.2/go.mod h1:U105tZXyTatxxOBlcObRgLb/ULvGgT2DJ1nq/8++P6Q= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 2bcbaf39..7040d2cf 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -13,7 +13,7 @@ import ( type EnvFile struct { Filepath string - Env []env.EnvLine + Env []env.EnvLineComment } func (e *EnvFile) Lookup(key string) (string, bool) { @@ -41,7 +41,7 @@ type PromptHelpers struct { // Ask will ask the user for input and return true (confirm) or false (no!) Ask func(logger logger.Logger, title string, defaultValue bool) bool // PromptForEnv is a helper for prompting the user to get a environment (or secret) value. You must do something with the result such as save it. - PromptForEnv func(logger logger.Logger, key string, isSecret bool, localenv map[string]string, osenv map[string]string) string + PromptForEnv func(logger logger.Logger, key string, isSecret bool, localenv map[string]string, osenv map[string]string, defaultValue string, placeholder string) string } type DeployPreflightCheckData struct {