forked from molecule-ai/molecule-core
The repo's own .env contains lines like
CONFIGS_DIR= # Path to workspace-configs-templates/...
where the value is empty + an inline comment. The pre-fix parser:
1. v = " # Path to ..."
2. TrimLeft → "# Path to ..."
3. Inline-comment loop looked for " #" or "\t#" — neither matches
because the leading whitespace is gone.
4. Returned the comment text as the value.
Result: os.Setenv("CONFIGS_DIR", "# Path to ...") clobbered the auto-
discovery fallback. The TemplatesHandler then opened the comment as
a directory, ReadDir errored silently, and GET /templates returned
[]. Canvas's Templates panel showed "No templates found in
workspace-configs-templates/" even though 8 valid templates existed
on disk.
Fix: strip leading whitespace from the value FIRST, then run a
position-aware comment scan that treats `#` as a comment marker iff
it's at the start of the (trimmed) value or preceded by whitespace.
A bare `#` mid-value (e.g. `KEY=token#fragment`) still survives.
Quoted-value handling moved above the comment scan so
`KEY="value # not"` keeps the `#` as part of the value — pulled the
quote-detection into the same TrimLeft-then-check shape as the bare
path. The unterminated-quote case still falls through to bare-value
handling.
Three regression tests added covering the exact .env line that
broke (`CONFIGS_DIR= # ...`), spaces-only with comment, and tab-
only with comment.
Verified end-to-end: GET /templates now returns all 8 templates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.6 KiB
Go
181 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// loadDotEnvIfPresent walks upward from CWD looking for a .env file and
|
|
// merges its KEY=VALUE pairs into the process environment. Already-set
|
|
// vars (e.g. from `docker run -e`, CI exports, or ad-hoc `KEY=val
|
|
// ./binary`) win over file values so operators can override without
|
|
// editing the file.
|
|
//
|
|
// Why walk upward: the binary may be launched from the monorepo root,
|
|
// the workspace-server subdir, or anywhere else the operator finds
|
|
// convenient. Walking upward from CWD finds the canonical .env
|
|
// (gitignored, lives at the monorepo root) regardless of cwd, so a
|
|
// fresh `go build -o /tmp/molecule-server ./cmd/server && /tmp/molecule-server`
|
|
// from any subdir picks up the same MOLECULE_ENV / DATABASE_URL / etc.
|
|
// the operator already has — without sourcing or `set -a`.
|
|
//
|
|
// Why no godotenv dep: the format we use is simple — KEY=VALUE with
|
|
// optional `#` comments and no interpolation — so a tiny in-tree parser
|
|
// is auditable, has no supply-chain surface, and avoids drift across
|
|
// repos where some teams configure godotenv differently.
|
|
//
|
|
// Why it's safe in production: the Dockerfile does not COPY .env into
|
|
// the image and `.env` is gitignored, so production containers have no
|
|
// .env on disk to load. If an operator goes out of their way to put one
|
|
// there, the explicit-env-wins rule above means container env still
|
|
// dominates.
|
|
func loadDotEnvIfPresent() {
|
|
path, ok := findDotEnv()
|
|
if !ok {
|
|
return
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
log.Printf(".env: open %s: %v (skipping)", path, err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
loaded := 0
|
|
skipped := 0
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
k, v, ok := parseDotEnvLine(scanner.Text())
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, exists := os.LookupEnv(k); exists {
|
|
skipped++
|
|
continue
|
|
}
|
|
if err := os.Setenv(k, v); err != nil {
|
|
log.Printf(".env: set %s: %v", k, err)
|
|
continue
|
|
}
|
|
loaded++
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
log.Printf(".env: scan %s: %v", path, err)
|
|
}
|
|
log.Printf(".env: %s — loaded %d, %d already set in env", path, loaded, skipped)
|
|
}
|
|
|
|
// findDotEnv returns the path of the nearest .env file walking upward
|
|
// from CWD. Capped at 6 levels so a deeply-nested launch dir doesn't
|
|
// scan the entire filesystem.
|
|
//
|
|
// Sentinel gate: only accept a .env that sits next to `workspace-server/`
|
|
// (the monorepo marker). Without it, a developer running the binary from
|
|
// `~/Documents/other-project/` would walk up to `~/.env` and load
|
|
// arbitrary variables — a real foot-gun on shared dev machines and a
|
|
// possible information-leak vector on bare-metal deploys. Skipping the
|
|
// match falls through to "no .env found" which is identical to today's
|
|
// pre-fix behavior (the operator must export env explicitly).
|
|
func findDotEnv() (string, bool) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
for i := 0; i < 6; i++ {
|
|
p := filepath.Join(dir, ".env")
|
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
|
if isMonorepoRoot(dir) {
|
|
return p, true
|
|
}
|
|
// .env exists here but the directory isn't the monorepo
|
|
// root — keep walking. Loading it could clobber
|
|
// environment with values from an unrelated project.
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// isMonorepoRoot returns true if `dir` looks like the molecule-core
|
|
// monorepo root — the directory that owns the .env we want to load.
|
|
// The marker is `workspace-server/go.mod`, which is the canonical
|
|
// in-tree go module and exists only in this monorepo. A simple
|
|
// `workspace-server/` directory check would false-positive on a fork
|
|
// that renamed the dir; the go.mod check is more precise.
|
|
func isMonorepoRoot(dir string) bool {
|
|
st, err := os.Stat(filepath.Join(dir, "workspace-server", "go.mod"))
|
|
return err == nil && !st.IsDir()
|
|
}
|
|
|
|
// parseDotEnvLine parses a single .env line. Returns (key, value, true)
|
|
// for KEY=VALUE pairs. Returns (_, _, false) for blanks, comments, and
|
|
// malformed lines. Handles:
|
|
// - leading `export ` prefix (so shell-friendly .env files written
|
|
// for `source .env` or direnv work without modification)
|
|
// - leading UTF-8 BOM on the first line (Windows editors)
|
|
// - inline `# comment` after a value when preceded by whitespace
|
|
// - surrounding `"` or `'` quotes on the value (stripped one matched
|
|
// pair); inside a quoted value, `#` is part of the value, not a
|
|
// comment marker
|
|
func parseDotEnvLine(line string) (string, string, bool) {
|
|
// Strip a UTF-8 BOM if present. bufio.Scanner doesn't filter it,
|
|
// so the very first line of a Windows-edited .env would otherwise
|
|
// produce a key like U+FEFF + "FOO" that os.Setenv silently accepts.
|
|
line = strings.TrimPrefix(line, "\ufeff")
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
return "", "", false
|
|
}
|
|
// Drop a leading `export ` so lines like `export FOO=bar` (the
|
|
// form direnv and many `.env` templates emit) don't end up as a
|
|
// junk key with an embedded space.
|
|
line = strings.TrimPrefix(line, "export ")
|
|
line = strings.TrimLeft(line, " \t") // re-trim in case `export` itself had trailing space
|
|
eq := strings.IndexByte(line, '=')
|
|
if eq <= 0 {
|
|
return "", "", false
|
|
}
|
|
k := strings.TrimSpace(line[:eq])
|
|
v := line[eq+1:]
|
|
// Trim leading whitespace so a quoted value's opening quote is at
|
|
// v[0]. The comment-detection loop below then treats the position
|
|
// after the trim as "start of value" — `KEY= # comment` has its
|
|
// `#` at the new v[0] (preceded only by whitespace in the source)
|
|
// and is correctly classified as an empty value followed by a
|
|
// comment, not as a value of `# comment`.
|
|
v = strings.TrimLeft(v, " \t")
|
|
// Quoted value: strip one matched pair of surrounding quotes and
|
|
// take the contents verbatim (no inline-comment splitting). Must
|
|
// happen BEFORE comment detection so `KEY="value # not a comment"`
|
|
// keeps the `#` as part of the value.
|
|
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') {
|
|
quote := v[0]
|
|
if end := strings.IndexByte(v[1:], quote); end >= 0 {
|
|
return k, v[1 : 1+end], true
|
|
}
|
|
// Unterminated quote — fall through to bare-value handling
|
|
// (treats the opening quote as a literal char in the value).
|
|
}
|
|
// Bare value: strip inline comment. A `#` is a comment marker iff
|
|
// it's at the start of the (trimmed) value OR is preceded by
|
|
// whitespace. `KEY=token#fragment` keeps the `#` as part of the
|
|
// value because v[i-1] is alphanum.
|
|
for i := 0; i < len(v); i++ {
|
|
if v[i] != '#' {
|
|
continue
|
|
}
|
|
if i == 0 || v[i-1] == ' ' || v[i-1] == '\t' {
|
|
v = v[:i]
|
|
break
|
|
}
|
|
}
|
|
return k, strings.TrimSpace(v), true
|
|
}
|