feat: add --env-file flag to develop.sh (#25621)

Adds `--env-file` to `scripts/develop.sh` to allow reading environment 
from a given file. This makes it easier to configure things like external 
auth providers, access URLs, and other dev-time settings without 
exporting a wall of environment variables in every shell session.

> Generated with [Coder Agents](https://coder.com/agents)
This commit is contained in:
Cian Johnston
2026-05-25 11:54:57 +01:00
committed by GitHub
parent ffc51ec8b3
commit a4afb9dfc6
4 changed files with 203 additions and 3 deletions
+69 -3
View File
@@ -27,6 +27,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
@@ -69,6 +70,24 @@ const (
)
func main() {
// Pre-parse --env-file before serpent runs so that variables from
// the file are visible to serpent's Env-tag resolution for other
// options. The flag is also registered in the serpent OptionSet
// below for --help discoverability.
envFile, err := parseEnvFileFlag()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "develop: %v\n", err)
os.Exit(1)
}
if envFile != "" {
n, err := loadEnvFile(envFile)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "develop: error loading env file %s: %v\n", envFile, err)
os.Exit(1)
}
_, _ = fmt.Fprintf(os.Stderr, "develop: loaded %d variable(s) from %s\n", n, envFile)
}
var cfg devConfig
cmd := &serpent.Command{
@@ -182,6 +201,12 @@ func main() {
Description: "Accept changed migration files and update tracking. Use when you've manually fixed the DB to match the new migrations.",
Value: serpent.BoolOf(&cfg.dbContinue),
},
{
Flag: "env-file",
Env: "CODER_DEV_ENV_FILE",
Description: "Path to a .env file to load before starting. Variables in the file do not override existing environment variables. Note: unquoted and double-quoted values undergo $VAR expansion against other entries in the same file (not the process environment); use single quotes for literal dollar signs.",
Value: serpent.StringOf(&cfg.envFile),
},
},
Handler: func(inv *serpent.Invocation) error {
cfg.serverExtraArgs = inv.Args
@@ -198,7 +223,7 @@ func main() {
},
}
err := cmd.Invoke(os.Args[1:]...).WithOS().Run()
err = cmd.Invoke(os.Args[1:]...).WithOS().Run()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
@@ -223,6 +248,9 @@ type devConfig struct {
dbRollback bool
dbReset bool
dbContinue bool
// envFile is populated by serpent for --help output; actual loading
// uses parseEnvFileFlag() before serpent runs.
envFile string
projectRoot string
binaryPath string
configDir string
@@ -435,8 +463,46 @@ func (c *devConfig) cmd(ctx context.Context, bin string, args ...string) *exec.C
return cmd
}
// filterEnv returns env with any variables whose key matches
// exclude removed.
// parseEnvFileFlag extracts the --env-file value from os.Args and
// CODER_DEV_ENV_FILE before serpent runs, so that loaded variables
// are visible to serpent's Env-tag resolution for other options.
func parseEnvFileFlag() (string, error) {
for i, arg := range os.Args[1:] {
if arg == "--env-file" {
if i+2 >= len(os.Args) {
return "", xerrors.New("--env-file requires a value")
}
return os.Args[i+2], nil
}
if v, ok := strings.CutPrefix(arg, "--env-file="); ok {
return v, nil
}
}
return os.Getenv("CODER_DEV_ENV_FILE"), nil
}
// loadEnvFile reads the file at path using godotenv and sets any variables
// not already present in the process environment. It returns the number of
// variables set.
func loadEnvFile(path string) (int, error) {
vars, err := godotenv.Read(path)
if err != nil {
return 0, err
}
var n int
for key, val := range vars {
if _, exists := os.LookupEnv(key); exists {
continue
}
if err := os.Setenv(key, val); err != nil {
return n, err
}
n++
}
return n, nil
}
// filterEnv returns env with any variables whose key matches exclude removed.
func filterEnv(env []string, exclude ...string) []string {
out := make([]string, 0, len(env))
for _, e := range env {
+131
View File
@@ -855,3 +855,134 @@ func TestPrometheusBannerEntry(t *testing.T) {
})
}
}
//nolint:paralleltest // loadEnvFile mutates process-global environment.
func TestLoadEnvFile(t *testing.T) {
t.Run("LoadsVariablesFromFile", func(t *testing.T) {
tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")
err := os.WriteFile(envFile, []byte(strings.Join([]string{
"# Comment line",
"",
"FOO_TEST_VAR=bar",
"export BAZ_TEST_VAR=qux",
`QUOTED_TEST_VAR="hello world"`,
"SINGLE_QUOTED_TEST_VAR='single quoted'",
}, "\n")), 0o600)
require.NoError(t, err)
// Ensure none are set beforehand.
t.Setenv("FOO_TEST_VAR", "")
os.Unsetenv("FOO_TEST_VAR")
t.Setenv("BAZ_TEST_VAR", "")
os.Unsetenv("BAZ_TEST_VAR")
t.Setenv("QUOTED_TEST_VAR", "")
os.Unsetenv("QUOTED_TEST_VAR")
t.Setenv("SINGLE_QUOTED_TEST_VAR", "")
os.Unsetenv("SINGLE_QUOTED_TEST_VAR")
n, err := loadEnvFile(envFile)
require.NoError(t, err)
assert.Equal(t, 4, n)
assert.Equal(t, "bar", os.Getenv("FOO_TEST_VAR"))
assert.Equal(t, "qux", os.Getenv("BAZ_TEST_VAR"))
assert.Equal(t, "hello world", os.Getenv("QUOTED_TEST_VAR"))
assert.Equal(t, "single quoted", os.Getenv("SINGLE_QUOTED_TEST_VAR"))
})
t.Run("DoesNotOverrideExisting", func(t *testing.T) {
tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")
err := os.WriteFile(envFile, []byte("EXISTING_TEST_VAR=new\n"), 0o600)
require.NoError(t, err)
t.Setenv("EXISTING_TEST_VAR", "original")
n, err := loadEnvFile(envFile)
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.Equal(t, "original", os.Getenv("EXISTING_TEST_VAR"))
})
t.Run("ErrorsOnMissingFile", func(t *testing.T) {
_, err := loadEnvFile("/nonexistent/path/.env")
require.Error(t, err)
})
t.Run("ErrorsOnEmptyPath", func(t *testing.T) {
// This tests the caller logic (main), but we verify loadEnvFile
// would error on empty path since godotenv.Read("") fails.
_, err := loadEnvFile("")
require.Error(t, err)
})
}
//nolint:paralleltest // parseEnvFileFlag mutates process-global os.Args.
func TestParseEnvFileFlag(t *testing.T) {
t.Run("FlagWithSpace", func(t *testing.T) {
orig := os.Args
t.Cleanup(func() { os.Args = orig })
os.Args = []string{"develop", "--env-file", "/tmp/test.env", "--port", "3000"}
result, err := parseEnvFileFlag()
require.NoError(t, err)
assert.Equal(t, "/tmp/test.env", result)
})
t.Run("FlagWithEquals", func(t *testing.T) {
orig := os.Args
t.Cleanup(func() { os.Args = orig })
os.Args = []string{"develop", "--env-file=/tmp/test.env", "--port", "3000"}
result, err := parseEnvFileFlag()
require.NoError(t, err)
assert.Equal(t, "/tmp/test.env", result)
})
t.Run("FallsBackToEnvVar", func(t *testing.T) {
orig := os.Args
t.Cleanup(func() { os.Args = orig })
os.Args = []string{"develop", "--port", "3000"}
t.Setenv("CODER_DEV_ENV_FILE", "/tmp/from-env.env")
result, err := parseEnvFileFlag()
require.NoError(t, err)
assert.Equal(t, "/tmp/from-env.env", result)
})
t.Run("FlagTakesPrecedenceOverEnvVar", func(t *testing.T) {
orig := os.Args
t.Cleanup(func() { os.Args = orig })
os.Args = []string{"develop", "--env-file", "/tmp/from-flag.env"}
t.Setenv("CODER_DEV_ENV_FILE", "/tmp/from-env.env")
result, err := parseEnvFileFlag()
require.NoError(t, err)
assert.Equal(t, "/tmp/from-flag.env", result)
})
t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) {
orig := os.Args
t.Cleanup(func() { os.Args = orig })
os.Args = []string{"develop", "--port", "3000"}
t.Setenv("CODER_DEV_ENV_FILE", "")
os.Unsetenv("CODER_DEV_ENV_FILE")
result, err := parseEnvFileFlag()
require.NoError(t, err)
assert.Equal(t, "", result)
})
t.Run("ErrorsWhenValueMissing", func(t *testing.T) {
orig := os.Args
t.Cleanup(func() { os.Args = orig })
os.Args = []string{"develop", "--env-file"}
_, err := parseEnvFileFlag()
require.Error(t, err)
assert.Contains(t, err.Error(), "--env-file requires a value")
})
}