|
|
|
@@ -0,0 +1,201 @@
|
|
|
|
|
## .env file support for the bot.
|
|
|
|
|
##
|
|
|
|
|
## Why: a dozen TR_* variables were exported in the shell that launched the
|
|
|
|
|
## server/GUI, and stale leftovers were repeatedly mistaken for live settings
|
|
|
|
|
## (e.g. a forgotten TR_RACK_TMHORIZON). This module lets those settings live in
|
|
|
|
|
## a file instead — and the FILE WINS over a leftover shell export.
|
|
|
|
|
##
|
|
|
|
|
## Values are applied with putEnv() before anything else reads the environment,
|
|
|
|
|
## so every existing reader (getEnv, existsEnv, the envInt/envFloat/envBool
|
|
|
|
|
## helpers, the PRESENCE-based knobs like TR_POWER_LOG) keeps working unchanged.
|
|
|
|
|
##
|
|
|
|
|
## Resolution order (first hit wins):
|
|
|
|
|
## 1. --env-file <path> (also --env-file=<path>)
|
|
|
|
|
## 2. TR_ENV_FILE
|
|
|
|
|
## 3. ./.env in the current working directory (the bot root when launched
|
|
|
|
|
## from the bot dir, which the launcher scripts do)
|
|
|
|
|
## 4. .env next to the executable
|
|
|
|
|
## 5. none
|
|
|
|
|
##
|
|
|
|
|
## An EXPLICIT request (1 or 2) that names a missing file is a hard error: an
|
|
|
|
|
## explicit request that silently does nothing is exactly the bug this fixes.
|
|
|
|
|
## A missing default file (3/4) is normal and silent.
|
|
|
|
|
##
|
|
|
|
|
## The boot report (env_report.nim) reads the resolved path/source and the set
|
|
|
|
|
## of keys that came from the file, so it can label values `(source: .env)`.
|
|
|
|
|
|
|
|
|
|
import std/[os, strutils, sets]
|
|
|
|
|
|
|
|
|
|
const EnvFileFlag* = "--env-file"
|
|
|
|
|
const EnvFileEnvVar* = "TR_ENV_FILE"
|
|
|
|
|
const DefaultEnvFile* = ".env"
|
|
|
|
|
|
|
|
|
|
type
|
|
|
|
|
EnvFileError* = object of CatchableError
|
|
|
|
|
## Raised when an explicitly requested env file cannot be used.
|
|
|
|
|
|
|
|
|
|
EnvEntry* = object
|
|
|
|
|
key*: string
|
|
|
|
|
value*: string
|
|
|
|
|
|
|
|
|
|
EnvConflict* = object
|
|
|
|
|
## A key present in BOTH the file and the real environment with different
|
|
|
|
|
## values. The file value is the one applied; `shellValue` is what it
|
|
|
|
|
## replaced (the stale leftover that used to win by accident).
|
|
|
|
|
key*: string
|
|
|
|
|
fileValue*: string
|
|
|
|
|
shellValue*: string
|
|
|
|
|
|
|
|
|
|
EnvFileChoice* = object
|
|
|
|
|
path*: string ## resolved path; "" means "no file"
|
|
|
|
|
source*: string ## "flag" | "TR_ENV_FILE" | "cwd" | "exe" | "none"
|
|
|
|
|
explicit*: bool ## requested via flag/env var (missing => hard error)
|
|
|
|
|
|
|
|
|
|
# ── resolved state (read by the boot report) ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
var
|
|
|
|
|
gEnvFilePath = ""
|
|
|
|
|
gEnvFileSource = "none"
|
|
|
|
|
gEnvFileKeys: HashSet[string]
|
|
|
|
|
gEnvFileLoaded = false
|
|
|
|
|
|
|
|
|
|
proc envFilePath*(): string = gEnvFilePath
|
|
|
|
|
proc envFileSource*(): string = gEnvFileSource
|
|
|
|
|
proc isFromEnvFile*(key: string): bool = key in gEnvFileKeys
|
|
|
|
|
|
|
|
|
|
# ── CLI parsing ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
proc flagEnvFilePath*(params: openArray[string] = commandLineParams()): string =
|
|
|
|
|
## The value of `--env-file` / `--env-file=`, or "" when absent. Raises when
|
|
|
|
|
## `--env-file` is the last argument (a request we cannot honour).
|
|
|
|
|
var i = 0
|
|
|
|
|
while i < params.len:
|
|
|
|
|
let p = params[i]
|
|
|
|
|
if p == EnvFileFlag:
|
|
|
|
|
if i + 1 < params.len:
|
|
|
|
|
return params[i + 1]
|
|
|
|
|
raise newException(EnvFileError, EnvFileFlag & " needs a path argument")
|
|
|
|
|
elif p.startsWith(EnvFileFlag & "="):
|
|
|
|
|
return p[EnvFileFlag.len + 1 .. ^1]
|
|
|
|
|
inc i
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
# ── resolution ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
proc chooseEnvFile*(flagValue, envValue, cwdCandidate, exeCandidate: string): EnvFileChoice =
|
|
|
|
|
## Pure resolution: flag, then TR_ENV_FILE, then `./.env`, then the `.env`
|
|
|
|
|
## next to the executable, then none.
|
|
|
|
|
if flagValue.len > 0:
|
|
|
|
|
return EnvFileChoice(path: flagValue, source: "flag", explicit: true)
|
|
|
|
|
if envValue.len > 0:
|
|
|
|
|
return EnvFileChoice(path: envValue, source: EnvFileEnvVar, explicit: true)
|
|
|
|
|
if fileExists(cwdCandidate):
|
|
|
|
|
return EnvFileChoice(path: cwdCandidate, source: "cwd", explicit: false)
|
|
|
|
|
if fileExists(exeCandidate):
|
|
|
|
|
return EnvFileChoice(path: exeCandidate, source: "exe", explicit: false)
|
|
|
|
|
return EnvFileChoice(path: "", source: "none", explicit: false)
|
|
|
|
|
|
|
|
|
|
# ── parsing ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
proc parseEnvFileContent*(content, filename: string): seq[EnvEntry] =
|
|
|
|
|
## Parse the usual .env shape: one KEY=VALUE per line, blank lines and `#`
|
|
|
|
|
## comments skipped, a leading `export ` tolerated, surrounding single or
|
|
|
|
|
## double quotes stripped, whitespace trimmed, an empty VALUE allowed.
|
|
|
|
|
##
|
|
|
|
|
## A malformed line RAISES with the file name and line number — it is never
|
|
|
|
|
## silently ignored, because a typo that does nothing is the failure mode we
|
|
|
|
|
## are fixing.
|
|
|
|
|
var lineno = 0
|
|
|
|
|
for raw in content.splitLines():
|
|
|
|
|
inc lineno
|
|
|
|
|
var line = raw
|
|
|
|
|
if line.len > 0 and line[^1] == '\r': line.setLen(line.len - 1)
|
|
|
|
|
let stripped = line.strip()
|
|
|
|
|
if stripped.len == 0 or stripped[0] == '#': continue
|
|
|
|
|
var body = stripped
|
|
|
|
|
if body.startsWith("export") and (body.len == 6 or body[6] in Whitespace):
|
|
|
|
|
body = body[6 .. ^1].strip()
|
|
|
|
|
let eq = body.find('=')
|
|
|
|
|
if eq < 0:
|
|
|
|
|
raise newException(ValueError,
|
|
|
|
|
filename & ":" & $lineno & ": expected KEY=VALUE, got: " & stripped)
|
|
|
|
|
let key = body[0 ..< eq].strip()
|
|
|
|
|
if key.len == 0:
|
|
|
|
|
raise newException(ValueError,
|
|
|
|
|
filename & ":" & $lineno & ": empty key in: " & stripped)
|
|
|
|
|
var value = body[eq + 1 .. ^1].strip()
|
|
|
|
|
if value.len >= 2 and
|
|
|
|
|
((value[0] == '"' and value[^1] == '"') or
|
|
|
|
|
(value[0] == '\'' and value[^1] == '\'')):
|
|
|
|
|
value = value[1 ..< value.len - 1]
|
|
|
|
|
result.add EnvEntry(key: key, value: value)
|
|
|
|
|
|
|
|
|
|
# ── applying ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
proc applyEnvFile*(path: string): seq[EnvConflict] =
|
|
|
|
|
## Read `path`, apply every entry with putEnv (the FILE WINS over the real
|
|
|
|
|
## environment), remember the from-file keys, and return the keys whose file
|
|
|
|
|
## value replaced a DIFFERENT exported value. Raises on a read error or a
|
|
|
|
|
## malformed line.
|
|
|
|
|
let entries = parseEnvFileContent(readFile(path), path)
|
|
|
|
|
for e in entries:
|
|
|
|
|
if existsEnv(e.key):
|
|
|
|
|
let shell = getEnv(e.key)
|
|
|
|
|
if shell != e.value:
|
|
|
|
|
result.add EnvConflict(key: e.key, fileValue: e.value, shellValue: shell)
|
|
|
|
|
putEnv(e.key, e.value)
|
|
|
|
|
gEnvFileKeys.incl e.key
|
|
|
|
|
|
|
|
|
|
proc loadEnvFile*(choice: EnvFileChoice): seq[EnvConflict] =
|
|
|
|
|
## Apply the chosen file. A missing EXPLICIT file raises EnvFileError; a
|
|
|
|
|
## missing default file is a silent no-op. No file at all is a silent no-op.
|
|
|
|
|
if choice.path.len == 0: return @[]
|
|
|
|
|
if not fileExists(choice.path):
|
|
|
|
|
if choice.explicit:
|
|
|
|
|
raise newException(EnvFileError, "env file not found: " & choice.path)
|
|
|
|
|
return @[]
|
|
|
|
|
result = applyEnvFile(choice.path)
|
|
|
|
|
|
|
|
|
|
proc bootDotEnv*() =
|
|
|
|
|
## Resolve and apply the .env file exactly once. Called at process start by
|
|
|
|
|
## env_boot.nim (imported first by ModularBot.nim) so every later module-level
|
|
|
|
|
## read sees the file values.
|
|
|
|
|
if gEnvFileLoaded: return
|
|
|
|
|
gEnvFileLoaded = true
|
|
|
|
|
|
|
|
|
|
var flag = ""
|
|
|
|
|
try:
|
|
|
|
|
flag = flagEnvFilePath()
|
|
|
|
|
except EnvFileError as e:
|
|
|
|
|
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
|
|
|
|
quit(1)
|
|
|
|
|
|
|
|
|
|
let envValue = if existsEnv(EnvFileEnvVar): getEnv(EnvFileEnvVar).strip() else: ""
|
|
|
|
|
let choice = chooseEnvFile(flag, envValue,
|
|
|
|
|
getCurrentDir() / DefaultEnvFile,
|
|
|
|
|
getAppDir() / DefaultEnvFile)
|
|
|
|
|
|
|
|
|
|
if choice.path.len == 0:
|
|
|
|
|
gEnvFilePath = ""
|
|
|
|
|
gEnvFileSource = "none"
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
let conflicts = loadEnvFile(choice)
|
|
|
|
|
gEnvFilePath = choice.path
|
|
|
|
|
gEnvFileSource = choice.source
|
|
|
|
|
for c in conflicts:
|
|
|
|
|
stderr.writeLine "[dotenv] " & c.key & " = " & c.fileValue &
|
|
|
|
|
" (from " & choice.path & ") overrides the exported value " & c.shellValue
|
|
|
|
|
except EnvFileError as e:
|
|
|
|
|
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
|
|
|
|
stderr.writeLine "[dotenv] it was requested explicitly (--env-file or " &
|
|
|
|
|
EnvFileEnvVar & "); refusing to start with the request silently ignored."
|
|
|
|
|
quit(1)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
stderr.writeLine "[dotenv] ERROR: " & e.msg
|
|
|
|
|
quit(1)
|
|
|
|
|
except CatchableError as e:
|
|
|
|
|
stderr.writeLine "[dotenv] ERROR: cannot use env file " & choice.path &
|
|
|
|
|
": " & e.msg
|
|
|
|
|
quit(1)
|