539 lines
20 KiB
Go
539 lines
20 KiB
Go
// Package config loads and validates the backend's runtime configuration.
|
|
//
|
|
// Configuration comes from the process environment. A .env file in the
|
|
// repository root is read first as a convenience for local development, and
|
|
// never overrides a variable that is already set — so an explicit
|
|
// `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file.
|
|
//
|
|
// Nothing here has a credential baked in. Load fails loudly rather than
|
|
// falling back to a default host, database or user, because a silent default
|
|
// is how a development process ends up pointed at the wrong database.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// defaultModel is what every reasoning tier routes to until a deployment says
|
|
// otherwise. Named once here so the three tiers cannot drift apart by accident.
|
|
const defaultModel = "claude-opus-5"
|
|
|
|
// Config is the whole of the Phase 1 configuration surface.
|
|
type Config struct {
|
|
AppEnv string
|
|
Log LogConfig
|
|
HTTP HTTPConfig
|
|
DB DBConfig
|
|
Seed SeedConfig
|
|
Model ModelConfig
|
|
Knowledge KnowledgeConfig
|
|
}
|
|
|
|
// KnowledgeConfig routes the retrieval layer's embedding provider.
|
|
//
|
|
// Anthropic does not serve embeddings, so the dense half of hybrid retrieval
|
|
// needs a separate credential. Voyage is the documented partner and the default.
|
|
//
|
|
// An empty key is legitimate: this service boots and serves without one, and
|
|
// retrieval degrades to keyword-only rather than failing — reported on every
|
|
// result, never silently. What is NOT legitimate is production running on the
|
|
// lexical stand-in, which is why that is a separate, deliberate opt-in rather
|
|
// than something an empty key falls back to.
|
|
type KnowledgeConfig struct {
|
|
// EmbedProvider names which embedder to use: "voyage", "ollama",
|
|
// "lexical", or "" to pick from what is configured.
|
|
//
|
|
// Explicit beats inferred here. The three differ in a way that is invisible
|
|
// from the outside — all of them return vectors and retrieval works with
|
|
// any of them — so a deployment silently running the stand-in would look
|
|
// exactly like one running a real model, right up until somebody phrased a
|
|
// question differently. Naming the provider makes the choice reviewable.
|
|
EmbedProvider string
|
|
|
|
// EmbedAPIKey is the hosted provider's credential (Voyage).
|
|
EmbedAPIKey string
|
|
|
|
// EmbedBaseURL is where a local model answers. Ollama's default is
|
|
// http://localhost:11434.
|
|
EmbedBaseURL string
|
|
|
|
EmbedModel string
|
|
EmbedDims int
|
|
|
|
// UseLexicalEmbedder swaps in the deterministic stand-in. Development only:
|
|
// it is not semantic, and a corpus indexed with it retrieves on word overlap
|
|
// alone. Load() refuses it outside development rather than trusting the
|
|
// operator to have read the comment.
|
|
//
|
|
// Kept alongside EmbedProvider for the deployments that already set it.
|
|
UseLexicalEmbedder bool
|
|
}
|
|
|
|
// ModelConfig routes an agent spec's reasoning tier to a model.
|
|
//
|
|
// A spec declares `reasoning: fast | balanced | deep`, never a model id, so the
|
|
// mapping is a deployment decision and changes without editing a definition.
|
|
// All three default to the same model: the tiers differ by *effort*, which the
|
|
// gateway owns, and a deployment that wants a cheaper model on the fast tier
|
|
// says so explicitly rather than inheriting a downgrade nobody chose.
|
|
//
|
|
// The API key may legitimately be empty outside production. This service has to
|
|
// boot without model credentials — migrations, seeding and every endpoint that
|
|
// is not an agent run work fine without one — so the failure belongs at the
|
|
// first model call, as a structured gateway.not_configured a run can end with,
|
|
// not at startup as a refusal to boot.
|
|
type ModelConfig struct {
|
|
APIKey string
|
|
Fast string
|
|
Balanced string
|
|
Deep string
|
|
MaxOutputTokens int
|
|
}
|
|
|
|
// SeedConfig locates the demo fixture. The file is generated from the frontend
|
|
// repository, so it lives beside the migrations rather than inside the Go
|
|
// module: regenerating it must not require rebuilding the binary.
|
|
type SeedConfig struct {
|
|
FixturePath string
|
|
}
|
|
|
|
type LogConfig struct {
|
|
Level string
|
|
}
|
|
|
|
type HTTPConfig struct {
|
|
Host string
|
|
Port int
|
|
ReadTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
|
|
// CookieSameSite is the SameSite attribute on the session cookie:
|
|
// "lax" (default), "none" or "strict".
|
|
//
|
|
// This exists because CORS is only half of what a cross-origin browser call
|
|
// needs, and the other half is easy to miss. SameSite is judged on SITE
|
|
// (registrable domain), not origin:
|
|
//
|
|
// app.krow.com → api.krow.com SAME site. Lax sends the cookie. ✓
|
|
// krow.vercel.app → api.krow.com CROSS site. Lax does NOT send it. ✗
|
|
// localhost:5173 → 127.0.0.1:8080 CROSS site — different hosts. ✗
|
|
//
|
|
// So a deployment whose frontend is on an unrelated domain gets a perfect
|
|
// set of CORS headers and still no session, because the browser never
|
|
// attaches the cookie. "none" is the only value that survives that, and it
|
|
// requires Secure, which means HTTPS.
|
|
//
|
|
// Default stays "lax": it is the safe value, it is correct for the
|
|
// same-site and same-origin deployments this is normally run as, and it
|
|
// gives CSRF protection that "none" gives up.
|
|
CookieSameSite string
|
|
|
|
// CORSOrigins is the exact set of browser origins allowed to call the API.
|
|
//
|
|
// It exists for one reason: in local development the Vite dev server is an
|
|
// origin of its own (http://localhost:5173) and the API is another
|
|
// (http://127.0.0.1:8080), so every fetch from the frontend is
|
|
// cross-origin. Empty means CORS is off and the API answers only
|
|
// same-origin callers, which is the correct posture everywhere the
|
|
// frontend is served from the same host as the API.
|
|
//
|
|
// Origins are matched exactly and echoed back one at a time. There is no
|
|
// wildcard and no pattern: "*" would let any page on the internet read
|
|
// this API, and once authentication exists that becomes a real hole rather
|
|
// than a theoretical one.
|
|
CORSOrigins []string
|
|
}
|
|
|
|
type DBConfig struct {
|
|
Host string
|
|
Port int
|
|
Name string
|
|
User string
|
|
Password string
|
|
Schema string
|
|
SSLMode string
|
|
MaxOpenConns int32
|
|
MinIdleConns int32
|
|
ConnMaxLifetime time.Duration
|
|
ConnectTimeout time.Duration
|
|
StatementTimeout time.Duration
|
|
}
|
|
|
|
// DSN builds a libpq-style connection URL.
|
|
//
|
|
// Every component is URL-escaped: the local database is called "Krow-force",
|
|
// which is both mixed-case and hyphenated, and a password may contain anything
|
|
// at all. Escaping is not optional here.
|
|
func (d DBConfig) DSN() string {
|
|
u := &url.URL{
|
|
Scheme: "postgres",
|
|
User: url.UserPassword(d.User, d.Password),
|
|
Host: fmt.Sprintf("%s:%d", d.Host, d.Port),
|
|
Path: "/" + d.Name,
|
|
}
|
|
q := u.Query()
|
|
q.Set("sslmode", d.SSLMode)
|
|
// Pin the schema on every connection so no query can accidentally resolve
|
|
// against a different one, and so nothing reaches for a system schema.
|
|
q.Set("search_path", d.Schema)
|
|
q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds())))
|
|
q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds())))
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// Redacted returns the DSN with the password replaced, for logs.
|
|
func (d DBConfig) Redacted() string {
|
|
u, err := url.Parse(d.DSN())
|
|
if err != nil {
|
|
return "postgres://<unparseable>"
|
|
}
|
|
if _, hasPassword := u.User.Password(); hasPassword {
|
|
u.User = url.UserPassword(u.User.Username(), "xxxxx")
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// Load reads the environment, applies defaults and validates the result.
|
|
func Load() (*Config, error) {
|
|
loadDotEnv(".env")
|
|
|
|
var missing []string
|
|
required := func(key string) string {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
missing = append(missing, key)
|
|
}
|
|
return v
|
|
}
|
|
|
|
cfg := &Config{
|
|
AppEnv: withDefault("APP_ENV", "development"),
|
|
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
|
|
HTTP: HTTPConfig{
|
|
Host: withDefault("HTTP_HOST", "127.0.0.1"),
|
|
Port: intDefault("HTTP_PORT", 8080),
|
|
ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second),
|
|
WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", 30*time.Second),
|
|
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
|
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
|
|
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
|
|
// Empty when unset, which is NOT the same as "lax": unset means "let
|
|
// the server derive it from the CORS posture", and an explicit value
|
|
// overrides that derivation. See Server.sessionSameSite.
|
|
CookieSameSite: strings.ToLower(strings.TrimSpace(os.Getenv("HTTP_COOKIE_SAMESITE"))),
|
|
},
|
|
Seed: SeedConfig{
|
|
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
|
},
|
|
Knowledge: KnowledgeConfig{
|
|
EmbedProvider: strings.ToLower(strings.TrimSpace(os.Getenv("EMBED_PROVIDER"))),
|
|
EmbedAPIKey: strings.TrimSpace(os.Getenv("VOYAGE_API_KEY")),
|
|
EmbedBaseURL: strings.TrimSpace(os.Getenv("EMBED_BASE_URL")),
|
|
// No default model or width here: they differ per provider, and one
|
|
// shared default would silently hand Ollama's dimensions to Voyage.
|
|
// Resolved where the provider is chosen — see runtime.NewEmbedder.
|
|
EmbedModel: strings.TrimSpace(os.Getenv("EMBED_MODEL")),
|
|
EmbedDims: intDefault("EMBED_DIMENSIONS", 0),
|
|
UseLexicalEmbedder: boolDefault("EMBED_USE_LEXICAL", false),
|
|
},
|
|
Model: ModelConfig{
|
|
APIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")),
|
|
Fast: withDefault("MODEL_FAST", defaultModel),
|
|
Balanced: withDefault("MODEL_BALANCED", defaultModel),
|
|
Deep: withDefault("MODEL_DEEP", defaultModel),
|
|
// 16k keeps a non-streaming response inside the SDK's HTTP
|
|
// timeout. The loop raises it and switches to streaming when it
|
|
// needs a long answer; this is the ceiling for a single
|
|
// unstreamed call, not the run's budget.
|
|
MaxOutputTokens: intDefault("MODEL_MAX_OUTPUT_TOKENS", 16000),
|
|
},
|
|
DB: DBConfig{
|
|
Host: required("DATABASE_HOST"),
|
|
Port: intDefault("DATABASE_PORT", 5432),
|
|
Name: required("DATABASE_NAME"),
|
|
User: required("DATABASE_USER"),
|
|
Password: os.Getenv("DATABASE_PASSWORD"), // may legitimately be empty (trust/peer auth)
|
|
Schema: withDefault("DATABASE_SCHEMA", "public"),
|
|
SSLMode: withDefault("DATABASE_SSLMODE", "disable"),
|
|
MaxOpenConns: int32(intDefault("DATABASE_MAX_OPEN_CONNS", 25)),
|
|
MinIdleConns: int32(intDefault("DATABASE_MIN_IDLE_CONNS", 2)),
|
|
ConnMaxLifetime: durationDefault("DATABASE_CONN_MAX_LIFETIME", 30*time.Minute),
|
|
ConnectTimeout: durationDefault("DATABASE_CONNECT_TIMEOUT", 5*time.Second),
|
|
StatementTimeout: durationDefault("DATABASE_STATEMENT_TIMEOUT", 10*time.Second),
|
|
},
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("missing required environment variables: %s "+
|
|
"(copy .env.example to .env and fill them in)", strings.Join(missing, ", "))
|
|
}
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
switch c.AppEnv {
|
|
case "development", "staging", "production":
|
|
default:
|
|
return fmt.Errorf("APP_ENV must be development, staging or production, got %q", c.AppEnv)
|
|
}
|
|
if c.HTTP.Port < 1 || c.HTTP.Port > 65535 {
|
|
return fmt.Errorf("HTTP_PORT out of range: %d", c.HTTP.Port)
|
|
}
|
|
if c.DB.Port < 1 || c.DB.Port > 65535 {
|
|
return fmt.Errorf("DATABASE_PORT out of range: %d", c.DB.Port)
|
|
}
|
|
// The application owns exactly one schema and it is never a system schema.
|
|
switch c.DB.Schema {
|
|
case "pg_catalog", "pg_toast", "information_schema":
|
|
return fmt.Errorf("DATABASE_SCHEMA must not be a PostgreSQL system schema, got %q", c.DB.Schema)
|
|
}
|
|
if strings.HasPrefix(c.DB.Schema, "pg_") {
|
|
return fmt.Errorf("DATABASE_SCHEMA must not start with \"pg_\", got %q", c.DB.Schema)
|
|
}
|
|
if c.DB.MinIdleConns > c.DB.MaxOpenConns {
|
|
return fmt.Errorf("DATABASE_MIN_IDLE_CONNS (%d) exceeds DATABASE_MAX_OPEN_CONNS (%d)",
|
|
c.DB.MinIdleConns, c.DB.MaxOpenConns)
|
|
}
|
|
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
|
|
return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production")
|
|
}
|
|
// A production deployment with no model credentials would accept agent runs
|
|
// and fail every one of them at the gateway. That is a boot-time
|
|
// misconfiguration wearing a runtime error's clothes, so it is caught here.
|
|
// Development is left alone deliberately: working on migrations or the
|
|
// definitions API must not require a key.
|
|
if c.AppEnv == "production" && c.Model.APIKey == "" {
|
|
return fmt.Errorf("ANTHROPIC_API_KEY is required when APP_ENV=production; " +
|
|
"without it every agent run fails at the model gateway")
|
|
}
|
|
if c.Model.MaxOutputTokens < 1 {
|
|
return fmt.Errorf("MODEL_MAX_OUTPUT_TOKENS must be at least 1, got %d", c.Model.MaxOutputTokens)
|
|
}
|
|
// The lexical embedder is a development stand-in that hashes words into a
|
|
// vector. It is not semantic, so a production corpus indexed with it would
|
|
// retrieve on word overlap alone — which looks like working retrieval and is
|
|
// not. Refused here rather than trusted to an operator's reading of a
|
|
// comment, because the failure is invisible from the outside: results come
|
|
// back, they are just the wrong ones.
|
|
switch c.Knowledge.EmbedProvider {
|
|
case "", "voyage", "ollama", "lexical":
|
|
default:
|
|
return fmt.Errorf("EMBED_PROVIDER must be voyage, ollama or lexical, got %q",
|
|
c.Knowledge.EmbedProvider)
|
|
}
|
|
if c.AppEnv == "production" &&
|
|
(c.Knowledge.UseLexicalEmbedder || c.Knowledge.EmbedProvider == "lexical") {
|
|
return fmt.Errorf("EMBED_USE_LEXICAL is a development stand-in and is not allowed when " +
|
|
"APP_ENV=production; it is not a semantic embedder and a corpus indexed with it " +
|
|
"retrieves on word overlap alone")
|
|
}
|
|
// Zero means "the provider's own default", resolved where the provider is
|
|
// chosen. Only a negative value is a mistake.
|
|
if c.Knowledge.EmbedDims < 0 {
|
|
return fmt.Errorf("EMBED_DIMENSIONS cannot be negative, got %d", c.Knowledge.EmbedDims)
|
|
}
|
|
for name, model := range map[string]string{
|
|
"MODEL_FAST": c.Model.Fast, "MODEL_BALANCED": c.Model.Balanced, "MODEL_DEEP": c.Model.Deep,
|
|
} {
|
|
if strings.TrimSpace(model) == "" {
|
|
return fmt.Errorf("%s must name a model", name)
|
|
}
|
|
}
|
|
switch c.HTTP.CookieSameSite {
|
|
// Unset. The server derives the mode from whether a CORS allowlist is
|
|
// configured; there is nothing to validate.
|
|
case "":
|
|
case "lax", "strict":
|
|
case "none":
|
|
// SameSite=None without Secure is ignored — and in current browsers,
|
|
// rejected outright — so the cookie would simply never be stored. The
|
|
// Secure flag is set for every APP_ENV except development, so this is
|
|
// the one combination that produces a silently sessionless deployment.
|
|
if c.AppEnv == "development" {
|
|
return fmt.Errorf("HTTP_COOKIE_SAMESITE=none requires the Secure cookie flag, " +
|
|
"which is not set when APP_ENV=development; SameSite=None over plain HTTP " +
|
|
"is rejected by browsers")
|
|
}
|
|
default:
|
|
return fmt.Errorf("HTTP_COOKIE_SAMESITE must be lax, none or strict, got %q",
|
|
c.HTTP.CookieSameSite)
|
|
}
|
|
for _, origin := range c.HTTP.CORSOrigins {
|
|
// "*" is rejected rather than quietly honoured. The middleware echoes a
|
|
// single matched origin, so a wildcard could only ever be a
|
|
// misunderstanding of what this setting does.
|
|
// "*" is not a stricter-than-necessary policy choice — it cannot work
|
|
// here at all. Authentication is a cookie, so the API must answer
|
|
// Access-Control-Allow-Credentials: true, and every browser REFUSES
|
|
// the pairing of that header with Allow-Origin: "*". A deployment
|
|
// configured this way would send correct-looking headers and have
|
|
// every authenticated call blocked client-side.
|
|
if origin == "*" {
|
|
return fmt.Errorf(`HTTP_CORS_ORIGINS must list explicit origins; "*" cannot be used ` +
|
|
`because this API authenticates with a cookie, and browsers reject ` +
|
|
`Access-Control-Allow-Origin: "*" together with credentials. ` +
|
|
`List each frontend origin, or serve the frontend from the API's own origin ` +
|
|
`(then leave this unset and CORS is not involved at all)`)
|
|
}
|
|
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
|
return fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
|
|
// localhost by default and 127.0.0.1 when asked, and a browser treats those two
|
|
// as different origins, so both are listed. 4173 is `vite preview`.
|
|
// 5174 is where Vite lands when 5173 is already taken, which happens whenever a
|
|
// second dev server is started; an origin missing from this list is refused at
|
|
// the preflight with a bare 403 and no CORS headers, which reads as a server
|
|
// fault rather than a misconfigured port.
|
|
var devCORSOrigins = []string{
|
|
"http://localhost:5173", "http://127.0.0.1:5173",
|
|
"http://localhost:5174", "http://127.0.0.1:5174",
|
|
"http://localhost:4173", "http://127.0.0.1:4173",
|
|
}
|
|
|
|
// corsOrigins reads HTTP_CORS_ORIGINS, a comma-separated allowlist.
|
|
//
|
|
// The development default is the Vite dev server, because that is the whole
|
|
// point of the setting in Phase 2D. Outside development the default is empty:
|
|
// a staging or production deployment that genuinely serves its frontend from
|
|
// another origin has to say so explicitly, rather than inheriting a list of
|
|
// localhost origins nobody reviewed.
|
|
func corsOrigins(appEnv string) []string {
|
|
raw, set := os.LookupEnv("HTTP_CORS_ORIGINS")
|
|
if !set {
|
|
if appEnv == "development" {
|
|
return devCORSOrigins
|
|
}
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, part := range strings.Split(raw, ",") {
|
|
// A trailing slash makes the string unequal to the Origin header the
|
|
// browser actually sends, which fails in a way that looks like a
|
|
// server bug rather than a typo.
|
|
if o := strings.TrimRight(strings.TrimSpace(part), "/"); o != "" {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func withDefault(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func intDefault(key string, fallback int) int {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
// boolDefault reads a boolean flag.
|
|
//
|
|
// An unparseable value falls back rather than erroring, matching intDefault.
|
|
// The one asymmetry worth knowing: only the explicit true spellings turn a flag
|
|
// on, so a typo'd "yes" leaves a feature off rather than on — the safe
|
|
// direction for every flag this file currently carries.
|
|
func boolDefault(key string, fallback bool) bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
|
case "":
|
|
return fallback
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func durationDefault(key string, fallback time.Duration) time.Duration {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
// loadDotEnv reads KEY=VALUE lines, walking up from the working directory so
|
|
// `go run ./cmd/api` finds the repository-root .env. Existing environment
|
|
// variables always win. Absence of the file is not an error.
|
|
func loadDotEnv(name string) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
path := dir + string(os.PathSeparator) + name
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
applyDotEnv(string(data))
|
|
return
|
|
}
|
|
parent := parentDir(dir)
|
|
if parent == dir {
|
|
return
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func parentDir(dir string) string {
|
|
i := strings.LastIndex(dir, string(os.PathSeparator))
|
|
if i <= 0 {
|
|
return dir
|
|
}
|
|
return dir[:i]
|
|
}
|
|
|
|
func applyDotEnv(content string) {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(strings.TrimPrefix(key, "export "))
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
if _, present := os.LookupEnv(key); !present {
|
|
_ = os.Setenv(key, value)
|
|
}
|
|
}
|
|
}
|