Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions cmd/lk/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ var (
Aliases: []string{"w"},
Usage: "Write environment variables to file",
},
&cli.BoolFlag{
Name: "overwrite",
Aliases: []string{"o"},
Usage: "Replace destination file instead of merging into existing contents",
},
&cli.StringFlag{
Name: "destination",
Aliases: []string{"d"},
Expand Down Expand Up @@ -371,12 +376,12 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error {
}
}
}
env, err := instantiateEnv(ctx, cmd, appName, addlEnv, envExampleFile)
env, err := instantiateEnv(ctx, cmd, appName, addlEnv, envExampleFile, nil)
if err != nil {
return err
}

bootstrap.WriteDotEnv(appName, envOutputFile, env)
bootstrap.WriteDotEnv(appName, envOutputFile, env, true)

if !cmd.IsSet("install") && !SkipPrompts(cmd) {
if err := huh.NewConfirm().
Expand Down Expand Up @@ -443,32 +448,47 @@ func manageEnv(ctx context.Context, cmd *cli.Command) error {
rootDir = "."
}

env, err := instantiateEnv(ctx, cmd, rootDir, nil, exampleFile)
overwrite := cmd.Bool("overwrite")

// When merging into an existing file, seed substitutions with its current
// values so prompts can be skipped and values already set are not clobbered
// by .env.example placeholders.
var priors map[string]string
if cmd.Bool("write") && !overwrite {
existing, err := bootstrap.ReadDotEnv(rootDir, destinationFile)
if err != nil {
return err
}
priors = existing
}

env, err := instantiateEnv(ctx, cmd, rootDir, nil, exampleFile, priors)
if err != nil {
return err
}

if cmd.Bool("write") {
return bootstrap.WriteDotEnv(rootDir, destinationFile, env)
return bootstrap.WriteDotEnv(rootDir, destinationFile, env, overwrite)
} else {
return bootstrap.PrintDotEnv(env)
}
}

func instantiateEnv(ctx context.Context, cmd *cli.Command, rootPath string, addlEnv *map[string]string, exampleFile string) (map[string]string, error) {
func instantiateEnv(ctx context.Context, cmd *cli.Command, rootPath string, addlEnv *map[string]string, exampleFile string, priors map[string]string) (map[string]string, error) {
env := map[string]string{}
if priors != nil {
maps.Copy(env, priors)
}
if _, err := requireProject(ctx, cmd); err != nil {
if !errors.Is(err, ErrNoProjectSelected) {
return nil, err
}
// if no project is selected, we prompt for all environment variables including LIVEKIT_ ones
} else {
env = map[string]string{
"LIVEKIT_API_KEY": project.APIKey,
"LIVEKIT_API_SECRET": project.APISecret,
"LIVEKIT_URL": project.URL,
"NEXT_PUBLIC_LIVEKIT_URL": project.URL,
}
env["LIVEKIT_API_KEY"] = project.APIKey
env["LIVEKIT_API_SECRET"] = project.APISecret
env["LIVEKIT_URL"] = project.URL
env["NEXT_PUBLIC_LIVEKIT_URL"] = project.URL
}

if addlEnv != nil {
Expand Down
137 changes: 135 additions & 2 deletions pkg/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"os"
"os/exec"
"path"
"regexp"
"runtime"
"strings"
"time"
Expand Down Expand Up @@ -303,15 +304,147 @@ func PrintDotEnv(envMap map[string]string) error {
return err
}

func WriteDotEnv(rootDir string, filePath string, envMap map[string]string) error {
// ReadDotEnv reads filePath under rootDir as a dotenv file. Returns (nil, nil)
// if the file does not exist.
func ReadDotEnv(rootDir string, filePath string) (map[string]string, error) {
envPath := path.Join(rootDir, filePath)
if _, err := os.Stat(envPath); errors.Is(err, fs.ErrNotExist) {
return nil, nil
} else if err != nil {
return nil, err
}
return godotenv.Read(envPath)
}

// WriteDotEnv writes envMap to filePath under rootDir. When overwrite is false
// and the file already exists, envMap is merged into the file in place: keys
// in envMap are updated (preserving any inline comments and the surrounding
// whitespace on those lines), keys not in envMap are preserved verbatim along
// with comments and blank lines, and any envMap keys not yet in the file are
// appended. When overwrite is true (or the file does not exist), the file is
// written fresh.
func WriteDotEnv(rootDir string, filePath string, envMap map[string]string, overwrite bool) error {
envLocalPath := path.Join(rootDir, filePath)

if !overwrite {
existing, err := os.ReadFile(envLocalPath)
if err == nil {
merged, err := mergeDotEnv(string(existing), envMap)
if err != nil {
return err
}
return os.WriteFile(envLocalPath, []byte(merged), 0700)
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
}

envContents, err := godotenv.Marshal(envMap)
if err != nil {
return err
}
envLocalPath := path.Join(rootDir, filePath)
return os.WriteFile(envLocalPath, []byte(envContents+"\n"), 0700)
}

var envAssignmentRe = regexp.MustCompile(`^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=`)

// parseEnvAssignment recognizes lines of the form
//
// [ws][export ]KEY[ws]=VALUE[trailing]
//
// and splits them into the leading prefix (whitespace + optional "export "),
// the key, and the trailing portion (any inline comment plus surrounding
// whitespace). For quoted values, trailing begins after the closing quote.
// For unquoted values, trailing begins at the whitespace preceding a `#`
// inline-comment marker (mirroring godotenv's rule that `#` must be preceded
// by whitespace to start a comment). ok is false for non-assignment lines.
func parseEnvAssignment(line string) (prefix, key, trailing string, ok bool) {
matches := envAssignmentRe.FindStringSubmatch(line)
if matches == nil {
return "", "", "", false
}
prefix = matches[1]
key = matches[2]
rest := line[len(matches[0]):]

peek := 0
for peek < len(rest) && (rest[peek] == ' ' || rest[peek] == '\t') {
peek++
}
if peek < len(rest) && (rest[peek] == '"' || rest[peek] == '\'') {
quote := rest[peek]
j := peek + 1
for j < len(rest) {
if quote == '"' && rest[j] == '\\' && j+1 < len(rest) {
j += 2
continue
}
if rest[j] == quote {
j++
break
}
j++
}
return prefix, key, rest[j:], true
}

valueEnd := len(rest)
for j := 0; j < len(rest); j++ {
if rest[j] == '#' && j > 0 && (rest[j-1] == ' ' || rest[j-1] == '\t') {
valueEnd = j - 1
for valueEnd > 0 && (rest[valueEnd-1] == ' ' || rest[valueEnd-1] == '\t') {
valueEnd--
}
break
}
}
return prefix, key, rest[valueEnd:], true
}

func mergeDotEnv(existing string, envMap map[string]string) (string, error) {
Comment thread
Topherhindman marked this conversation as resolved.
lines := strings.Split(existing, "\n")
seen := make(map[string]bool, len(envMap))

for i, line := range lines {
prefix, key, trailing, ok := parseEnvAssignment(line)
if !ok {
continue
}
newVal, exists := envMap[key]
if !exists {
continue
}
rendered, err := godotenv.Marshal(map[string]string{key: newVal})
if err != nil {
return "", err
}
lines[i] = prefix + rendered + trailing
seen[key] = true
}

result := strings.Join(lines, "\n")

unseen := map[string]string{}
for k, v := range envMap {
if !seen[k] {
unseen[k] = v
}
}
if len(unseen) > 0 {
rendered, err := godotenv.Marshal(unseen)
if err != nil {
return "", err
}
if !strings.HasSuffix(result, "\n") {
result += "\n"
}
result += rendered + "\n"
} else if !strings.HasSuffix(result, "\n") {
result += "\n"
}
return result, nil
}

func CloneTemplate(url, dir string) (string, string, error) {
var stdout = strings.Builder{}
var stderr = strings.Builder{}
Expand Down
Loading
Loading