diff --git a/cmd/lk/app.go b/cmd/lk/app.go index 84540139..7b81deaf 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -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"}, @@ -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(). @@ -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 { diff --git a/pkg/bootstrap/bootstrap.go b/pkg/bootstrap/bootstrap.go index f8f1ebcf..5fbd7026 100644 --- a/pkg/bootstrap/bootstrap.go +++ b/pkg/bootstrap/bootstrap.go @@ -29,6 +29,7 @@ import ( "os" "os/exec" "path" + "regexp" "runtime" "strings" "time" @@ -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) { + 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{} diff --git a/pkg/bootstrap/bootstrap_test.go b/pkg/bootstrap/bootstrap_test.go new file mode 100644 index 00000000..a35a8fc9 --- /dev/null +++ b/pkg/bootstrap/bootstrap_test.go @@ -0,0 +1,618 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bootstrap + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMergeDotEnv(t *testing.T) { + tests := []struct { + name string + existing string + envMap map[string]string + wantSubs []string // substrings that must be present in the result + notWantSubs []string // substrings that must NOT be present in the result + }{ + { + name: "appends new keys when existing is empty", + existing: "", + envMap: map[string]string{"FOO": "bar"}, + wantSubs: []string{`FOO="bar"`}, + }, + { + name: "updates existing key value in place", + existing: "FOO=oldval\n", + envMap: map[string]string{"FOO": "newval"}, + wantSubs: []string{`FOO="newval"`}, + notWantSubs: []string{"oldval"}, + }, + { + name: "preserves comments", + existing: "# leading comment\nFOO=old\n# trailing comment\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{"# leading comment", "# trailing comment", `FOO="new"`}, + }, + { + name: "preserves inline comments", + existing: "FOO=old # inline comment\nBAR=2 # another comment\n", + envMap: map[string]string{"FOO": "new", "BAR": "2"}, + wantSubs: []string{`FOO="new" # inline comment`, `BAR=2 # another comment`}, + }, + { + name: "preserves blank lines", + existing: "A=1\n\n\nB=2\n", + envMap: map[string]string{"A": "10"}, + // godotenv emits numeric strings without quotes (e.g. A=10). + wantSubs: []string{"A=10", "B=2", "\n\n\n"}, + }, + { + name: "preserves unrecognized keys", + existing: "KEEP_ME=keepvalue\nUPDATE_ME=old\n", + envMap: map[string]string{"UPDATE_ME": "new"}, + wantSubs: []string{"KEEP_ME=keepvalue", `UPDATE_ME="new"`}, + }, + { + name: "appends keys that aren't already present", + existing: "A=1\n", + envMap: map[string]string{"A": "1", "NEW_KEY": "newval"}, + wantSubs: []string{`NEW_KEY="newval"`}, + }, + { + name: "preserves export directive when updating", + existing: "export FOO=oldval\n", + envMap: map[string]string{"FOO": "newval"}, + wantSubs: []string{`export FOO="newval"`}, + notWantSubs: []string{"oldval"}, + }, + { + name: "preserves leading whitespace before key", + existing: " FOO=oldval\n", + envMap: map[string]string{"FOO": "newval"}, + wantSubs: []string{` FOO="newval"`}, + notWantSubs: []string{"oldval"}, + }, + { + name: "preserves leading whitespace and export together", + existing: " export FOO=oldval\n", + envMap: map[string]string{"FOO": "newval"}, + wantSubs: []string{` export FOO="newval"`}, + notWantSubs: []string{"oldval"}, + }, + { + name: "handles spaces around equals sign", + existing: "FOO = oldval\n", + envMap: map[string]string{"FOO": "newval"}, + wantSubs: []string{`FOO="newval"`}, + }, + { + name: "rewrites quoted values with new quoted value", + existing: "FOO=\"quoted old\"\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new"`}, + notWantSubs: []string{ + "quoted old", + }, + }, + { + name: "rewrites single-quoted values", + existing: "FOO='single quoted'\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new"`}, + notWantSubs: []string{"single quoted"}, + }, + { + name: "values with spaces are properly quoted", + existing: "MSG=old\n", + envMap: map[string]string{"MSG": "hello world"}, + wantSubs: []string{`MSG="hello world"`}, + }, + { + name: "values with double quotes are escaped", + existing: "Q=old\n", + envMap: map[string]string{"Q": `say "hi"`}, + wantSubs: []string{`Q="say \"hi\""`}, + }, + { + name: "values with equals signs in content are handled", + existing: "EQ=old\n", + envMap: map[string]string{"EQ": "a=b=c"}, + wantSubs: []string{`EQ="a=b=c"`}, + }, + { + name: "values with newlines are escaped", + existing: "MULTILINE=old\n", + envMap: map[string]string{"MULTILINE": "line1\nline2"}, + wantSubs: []string{`MULTILINE="line1\nline2"`}, + }, + { + name: "empty envMap leaves existing content intact", + existing: "# a comment\nA=1\nB=2\n", + envMap: map[string]string{}, + wantSubs: []string{"# a comment", "A=1", "B=2"}, + }, + { + name: "multiple matching keys updated", + existing: "A=oldA\nB=oldB\nC=oldC\n", + envMap: map[string]string{"A": "newA", "B": "newB"}, + wantSubs: []string{`A="newA"`, `B="newB"`, "C=oldC"}, + notWantSubs: []string{"oldA", "oldB"}, + }, + { + name: "appended keys appear after existing content", + existing: "EXISTING=val\n", + envMap: map[string]string{"NEW_ONE": "1"}, + wantSubs: []string{"EXISTING=val"}, + }, + { + name: "key with digit and underscore in name is handled", + existing: "NEXT_PUBLIC_KEY_1=old\n", + envMap: map[string]string{"NEXT_PUBLIC_KEY_1": "new"}, + wantSubs: []string{`NEXT_PUBLIC_KEY_1="new"`}, + }, + { + name: "ignores lines that look like comments with equals", + existing: "# FAKE=injection\nREAL=old\n", + envMap: map[string]string{"REAL": "new", "FAKE": "ignored"}, + wantSubs: []string{"# FAKE=injection", `REAL="new"`, `FAKE="ignored"`}, + }, + { + name: "preserves inline comment on unquoted value", + existing: "FOO=old # explains the value\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new" # explains the value`}, + notWantSubs: []string{"old"}, + }, + { + name: "preserves inline comment on quoted value", + existing: "FOO=\"old value\" # explains\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new" # explains`}, + }, + { + name: "preserves multiple spaces before inline comment", + existing: "FOO=old # two spaces\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new" # two spaces`}, + }, + { + name: "preserves tab before inline comment", + existing: "FOO=old\t# tabbed comment\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{"FOO=\"new\"\t# tabbed comment"}, + }, + { + name: "treats hash inside unquoted value as part of value (no space before)", + existing: "URL=https://x.com/#fragment\n", + envMap: map[string]string{"URL": "https://y.com"}, + // the new value replaces the entire old value; no trailing comment + wantSubs: []string{`URL="https://y.com"`}, + notWantSubs: []string{"#fragment"}, + }, + { + name: "preserves inline comment with export prefix", + existing: "export FOO=old # note\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`export FOO="new" # note`}, + }, + { + name: "preserves inline comment with leading whitespace", + existing: " FOO=old # note\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{` FOO="new" # note`}, + }, + { + name: "preserves inline comment when value is empty", + existing: "FOO= # note about empty\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new" # note about empty`}, + }, + { + name: "hash inside double-quoted value is not a comment", + existing: "FOO=\"contains # in value\"\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new"`}, + // the old value is fully replaced; '#' should not leak into output + notWantSubs: []string{"contains # in value"}, + }, + { + name: "no inline comment yields no trailing content", + existing: "FOO=old\n", + envMap: map[string]string{"FOO": "new"}, + wantSubs: []string{`FOO="new"`}, + notWantSubs: []string{"#"}, + }, + { + name: "preserves inline comments on lines we don't touch", + existing: "KEEP=stable # has a note\nUPDATE=old\n", + envMap: map[string]string{"UPDATE": "new"}, + wantSubs: []string{"KEEP=stable # has a note", `UPDATE="new"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := mergeDotEnv(tt.existing, tt.envMap) + if err != nil { + t.Fatalf("mergeDotEnv returned error: %v", err) + } + for _, s := range tt.wantSubs { + if !strings.Contains(got, s) { + t.Errorf("expected output to contain %q\n---\nGot:\n%s", s, got) + } + } + for _, s := range tt.notWantSubs { + if strings.Contains(got, s) { + t.Errorf("expected output NOT to contain %q\n---\nGot:\n%s", s, got) + } + } + }) + } +} + +func TestMergeDotEnv_TrailingNewline(t *testing.T) { + tests := []struct { + name string + existing string + envMap map[string]string + }{ + {"existing has trailing newline", "A=1\n", map[string]string{"A": "2"}}, + {"existing has no trailing newline", "A=1", map[string]string{"A": "2"}}, + {"no merge needed, trailing newline present", "A=1\n", map[string]string{}}, + {"no merge needed, no trailing newline", "A=1", map[string]string{}}, + {"new keys appended, no trailing newline", "A=1", map[string]string{"B": "2"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := mergeDotEnv(tt.existing, tt.envMap) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(got, "\n") { + t.Errorf("expected output to end with newline; got %q", got) + } + }) + } +} + +func TestMergeDotEnv_DoesNotDuplicateKeys(t *testing.T) { + got, err := mergeDotEnv("FOO=old\n", map[string]string{"FOO": "new"}) + if err != nil { + t.Fatal(err) + } + if count := strings.Count(got, "FOO="); count != 1 { + t.Errorf("expected FOO to appear exactly once, got %d occurrences\n---\n%s", count, got) + } +} + +func TestMergeDotEnv_AppendsAllNewKeysWhenNoneMatch(t *testing.T) { + got, err := mergeDotEnv("EXISTING=val\n", map[string]string{"A": "alpha", "B": "beta"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "EXISTING=val") { + t.Errorf("existing key not preserved: %s", got) + } + if !strings.Contains(got, `A="alpha"`) { + t.Errorf("A not appended: %s", got) + } + if !strings.Contains(got, `B="beta"`) { + t.Errorf("B not appended: %s", got) + } +} + +func TestReadDotEnv_FileMissing(t *testing.T) { + dir := t.TempDir() + got, err := ReadDotEnv(dir, ".env.missing") + if err != nil { + t.Fatalf("ReadDotEnv returned error: %v", err) + } + if got != nil { + t.Errorf("expected nil map for missing file, got %v", got) + } +} + +func TestReadDotEnv_ReadsExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("# header\nFOO=bar\nBAZ=\"qux quux\"\nexport HELLO=world\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := ReadDotEnv(dir, ".env") + if err != nil { + t.Fatalf("ReadDotEnv returned error: %v", err) + } + if got["FOO"] != "bar" { + t.Errorf("expected FOO=bar, got %q", got["FOO"]) + } + if got["BAZ"] != "qux quux" { + t.Errorf("expected BAZ=qux quux, got %q", got["BAZ"]) + } + if got["HELLO"] != "world" { + t.Errorf("expected HELLO=world (export stripped), got %q", got["HELLO"]) + } +} + +func TestReadDotEnv_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env.empty") + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + got, err := ReadDotEnv(dir, ".env.empty") + if err != nil { + t.Fatalf("ReadDotEnv returned error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map for empty file, got %v", got) + } +} + +func TestReadDotEnv_JoinsRootDirAndPath(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "nested") + if err := os.Mkdir(sub, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, ".env"), []byte("X=y\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := ReadDotEnv(sub, ".env") + if err != nil { + t.Fatal(err) + } + if got["X"] != "y" { + t.Errorf("expected X=y, got %v", got) + } +} + +func TestWriteDotEnv_FreshFile(t *testing.T) { + for _, overwrite := range []bool{true, false} { + name := "merge_mode" + if overwrite { + name = "overwrite_mode" + } + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := WriteDotEnv(dir, ".env", map[string]string{"FOO": "bar"}, overwrite); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), `FOO="bar"`) { + t.Errorf("expected FOO=bar in output, got: %s", contents) + } + }) + } +} + +func TestWriteDotEnv_MergesByDefault(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + existing := "# header comment\nKEEP=keepval\nUPDATE=oldval\n\nexport DEBUG=true\n" + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatal(err) + } + + err := WriteDotEnv(dir, ".env", map[string]string{ + "UPDATE": "newval", + "APPEND": "added", + "DEBUG": "false", + }, false) + if err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(got) + + checks := []struct { + name string + contain bool + sub string + }{ + {"comment preserved", true, "# header comment"}, + {"unrecognized key preserved", true, "KEEP=keepval"}, + {"existing key updated", true, `UPDATE="newval"`}, + {"old value removed", false, "UPDATE=oldval"}, + {"new key appended", true, `APPEND="added"`}, + {"export prefix preserved", true, `export DEBUG="false"`}, + {"export old value removed", false, "DEBUG=true"}, + {"blank line preserved", true, "\n\n"}, + } + for _, c := range checks { + got := strings.Contains(s, c.sub) + if got != c.contain { + t.Errorf("%s: contains(%q)=%v, want %v\n---\n%s", c.name, c.sub, got, c.contain, s) + } + } +} + +func TestWriteDotEnv_OverwriteFullyReplaces(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + existing := "# old comment\nKEEP=value\nUPDATE=old\n" + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatal(err) + } + + if err := WriteDotEnv(dir, ".env", map[string]string{"ONLY": "value"}, true); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(got) + if strings.Contains(s, "# old comment") { + t.Errorf("comment should have been wiped by overwrite, got: %s", s) + } + if strings.Contains(s, "KEEP") { + t.Errorf("preserved key should have been wiped by overwrite, got: %s", s) + } + if strings.Contains(s, "UPDATE") { + t.Errorf("old key should have been wiped by overwrite, got: %s", s) + } + if !strings.Contains(s, `ONLY="value"`) { + t.Errorf("expected only new key, got: %s", s) + } +} + +func TestWriteDotEnv_MergeIntoMissingFile(t *testing.T) { + dir := t.TempDir() + if err := WriteDotEnv(dir, ".env", map[string]string{"FOO": "bar"}, false); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), `FOO="bar"`) { + t.Errorf("expected FOO=bar in fresh write, got: %s", contents) + } +} + +func TestWriteDotEnv_MergePreservesUnrecognized(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("CUSTOM_VAR=custom_value\n"), 0600); err != nil { + t.Fatal(err) + } + if err := WriteDotEnv(dir, ".env", map[string]string{"OTHER": "value"}, false); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(path) + s := string(got) + if !strings.Contains(s, "CUSTOM_VAR=custom_value") { + t.Errorf("expected unrecognized key to be preserved, got: %s", s) + } + if !strings.Contains(s, `OTHER="value"`) { + t.Errorf("expected new key to be appended, got: %s", s) + } +} + +func TestWriteDotEnv_MergeRoundtripIsStable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + existing := "# header\nA=1\nB=2\n" + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatal(err) + } + // First merge with empty map should be effectively a no-op (content-wise). + if err := WriteDotEnv(dir, ".env", map[string]string{}, false); err != nil { + t.Fatal(err) + } + first, _ := os.ReadFile(path) + // Second merge with the same empty map should yield identical output. + if err := WriteDotEnv(dir, ".env", map[string]string{}, false); err != nil { + t.Fatal(err) + } + second, _ := os.ReadFile(path) + if string(first) != string(second) { + t.Errorf("merge is not stable across runs:\nfirst:\n%s\nsecond:\n%s", first, second) + } + if !strings.Contains(string(first), "# header") || !strings.Contains(string(first), "A=1") || !strings.Contains(string(first), "B=2") { + t.Errorf("merge with empty map should preserve content, got: %s", first) + } +} + +func TestInstantiateDotEnv_NoExampleFile(t *testing.T) { + dir := t.TempDir() + got, err := InstantiateDotEnv(context.Background(), dir, ".env.example", map[string]string{"X": "y"}, false, func(k, v string) (string, error) { + t.Fatalf("prompt should not be invoked when example file is absent") + return v, nil + }) + if err != nil { + t.Fatal(err) + } + if got["X"] != "y" { + t.Errorf("expected substitutions to be returned, got %v", got) + } +} + +func TestInstantiateDotEnv_SubstitutesWithoutPrompting(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env.example"), []byte("API_KEY=\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := InstantiateDotEnv(context.Background(), dir, ".env.example", map[string]string{"API_KEY": "real"}, false, func(k, v string) (string, error) { + t.Fatalf("prompt should not be invoked for keys present in substitutions; called for %s", k) + return v, nil + }) + if err != nil { + t.Fatal(err) + } + if got["API_KEY"] != "real" { + t.Errorf("expected API_KEY=real, got %v", got) + } +} + +func TestInstantiateDotEnv_PromptsForUnknownKeys(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env.example"), []byte("OPENAI_KEY=\n"), 0600); err != nil { + t.Fatal(err) + } + called := 0 + got, err := InstantiateDotEnv(context.Background(), dir, ".env.example", map[string]string{}, false, func(k, oldValue string) (string, error) { + called++ + return "prompted-" + oldValue, nil + }) + if err != nil { + t.Fatal(err) + } + if called != 1 { + t.Errorf("expected prompt to be called exactly once, got %d", called) + } + if got["OPENAI_KEY"] != "prompted-" { + t.Errorf("expected prompted value, got %v", got) + } +} + +func TestInstantiateDotEnv_PriorsAsSubstitutionsSkipPrompt(t *testing.T) { + // Simulates how manageEnv injects existing destination values as substitutions + // so the user isn't re-prompted for values that are already set. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env.example"), []byte("API_KEY=\nOTHER=\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := InstantiateDotEnv(context.Background(), dir, ".env.example", map[string]string{ + "API_KEY": "from-priors", + }, false, func(k, oldValue string) (string, error) { + // Only OTHER should be prompted. + if k != "OTHER" { + t.Errorf("did not expect prompt for %q", k) + } + return "prompted", nil + }) + if err != nil { + t.Fatal(err) + } + if got["API_KEY"] != "from-priors" { + t.Errorf("expected prior value for API_KEY, got %q", got["API_KEY"]) + } + if got["OTHER"] != "prompted" { + t.Errorf("expected prompted value for OTHER, got %q", got["OTHER"]) + } +} diff --git a/pkg/portaudio/pa_src b/pkg/portaudio/pa_src index d5b81b82..b0fe9de7 160000 --- a/pkg/portaudio/pa_src +++ b/pkg/portaudio/pa_src @@ -1 +1 @@ -Subproject commit d5b81b82f13ae8498f02e27595aa9c50ab2623db +Subproject commit b0fe9de7ec86ebe5a26086f1d662ab74d7ebfae4