Independent code review surfaced two required documentation fixes and one growth-correctness gap. All addressed here. Auto-fit gate (useCanvasViewport): The previous "subtree-grew-by-count" check missed the delete-then-add case: subtree of 6 → delete one → 5 → a different child arrives → 6 again. A length-only comparison reads no growth and the fit is skipped, leaving the new node off-screen. Switched to an id-set membership snapshot so any brand-new id forces the fit even when the count is unchanged. The gate logic is now extracted as a pure exported function `shouldFitGrowing(currentIds, prevIds, userPannedAt, lastAutoFitAt)` so the regression-prone decision can be unit-tested in isolation without standing up React Flow + DOM event refs. 8 cases cover: first-fit, empty-prior, brand-new id, status-update with user pan, no-pan-ever, pan-before-last-fit, delete-then-add same length, and shrink-only with user pan. Parser parity (dotenv.go + next.config.ts): Existing-env semantics were undocumented in both parsers. Both now explicitly note that an explicitly-set empty string (`KEY=` from the parent shell) counts as "set" — the file value does NOT backfill — matching the Go (os.LookupEnv) and Node (`process.env[k] !== undefined`) primitives. `export ` prefix uses a literal space; `export\tFOO=bar` is intentionally rejected. Added the same comment in both parsers to lock in this parity invariant since the commit message claims "if one parser changes, the other has to." Skipped (per analysis): - Drag-pan respect for left-click drag-pan during deploy. The growth-check safety net means any pan gets overridden on the next arrival anyway, which is the desired behavior for the "watch the org deploy" use case. After deploy completes, no more fit-deploying-org events fire so drag-pan works freely. - Map cleanup for lastFitSubtreeIdsRef. Per-tab session, UUID keys, tiny entries — not worth the cleanup hook. 993 canvas tests pass (8 new); Go dotenv tests pass; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
7.2 KiB
Go
191 lines
7.2 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
|
|
}
|
|
// Existing env wins. NOTE: an explicitly-set empty string
|
|
// (`KEY=` exported from a parent shell) counts as "set" — we
|
|
// keep the empty value rather than backfilling from the file.
|
|
// Matches Node's `process.env[k] !== undefined` check in the
|
|
// canvas's next.config.ts loader so both processes treat the
|
|
// same input identically. Operators who want the file value
|
|
// to win must `unset KEY` in the launching shell.
|
|
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 ` (literal space — `export\tFOO=bar`
|
|
// with a tab is intentionally rejected, matching the TS mirror in
|
|
// canvas/next.config.ts. shells emit `export ` with a space; tabs
|
|
// would only appear in hand-mangled files.) 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
|
|
}
|