obm/internal/config/dotenv.go
MermaidMan 33d9a2cb2e deploy walkthrough, API validation, inference client, Hetzner provider
- Interactive deploy command with 8-step walkthrough:
  framework → provider → token → SSH → server → inference → tailscale → discord
- .env file generation from walkthrough config
- DeploymentConfig struct with framework-aware defaults
- Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic
- Hetzner Cloud provider: token validation, SSH key listing
- DotEnv parser/writer with schema validation
- Destroy command with confirmation prompt
- Validation subcommand for checking existing .env files
- All tests passing, go vet clean
2026-05-22 15:29:27 +00:00

177 lines
4.2 KiB
Go

package config
import (
"bufio"
"fmt"
"os"
"strings"
)
// DotEnvFile represents a parsed .env file with key-value pairs and comments.
type DotEnvFile struct {
// Values maps env key (with TF_VAR_ prefix) to its value.
Values map[string]string
// Lines preserves original line order for round-tripping.
Lines []EnvLine
// Path is the file path this was loaded from.
Path string
}
// EnvLine represents a single line in a .env file.
type EnvLine struct {
Key string // Empty for comment/blank lines
Value string // Raw value (without quotes)
RawLine string // Original line text
IsComment bool
}
// ParseDotEnv reads and parses a .env file.
// Handles:
// - KEY=VALUE assignments
// - Comments (# prefix)
// - Quoted values (single and double quotes)
// - Empty lines
// - Inline comments after values
func ParseDotEnv(path string) (*DotEnvFile, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening .env file %s: %w", path, err)
}
defer f.Close()
env := &DotEnvFile{
Values: make(map[string]string),
Path: path,
}
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
raw := scanner.Text()
trimmed := strings.TrimSpace(raw)
line := EnvLine{RawLine: raw}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
line.IsComment = true
env.Lines = append(env.Lines, line)
continue
}
// Parse KEY=VALUE
idx := strings.Index(trimmed, "=")
if idx < 0 {
// Not a valid assignment, treat as comment
line.IsComment = true
env.Lines = append(env.Lines, line)
continue
}
key := strings.TrimSpace(trimmed[:idx])
val := strings.TrimSpace(trimmed[idx+1:])
// Strip inline comment (only outside quotes)
val = stripInlineComment(val)
// Unquote
val = unquoteValue(val)
line.Key = key
line.Value = val
env.Values[key] = val
env.Lines = append(env.Lines, line)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading .env file %s: %w", path, err)
}
return env, nil
}
// GetVar returns the value for a TF variable by name.
// It tries both "TF_VAR_<name>" and "<name>" as keys.
func (e *DotEnvFile) GetVar(name string) (string, bool) {
if v, ok := e.Values["TF_VAR_"+name]; ok {
return v, true
}
if v, ok := e.Values[name]; ok {
return v, true
}
return "", false
}
// SetVar sets a TF variable value (stored with TF_VAR_ prefix).
func (e *DotEnvFile) SetVar(name, value string) {
key := "TF_VAR_" + name
e.Values[key] = value
// Update existing line or add new
for i, l := range e.Lines {
if l.Key == key {
e.Lines[i].Value = value
return
}
}
e.Lines = append(e.Lines, EnvLine{Key: key, Value: value})
}
// ToConfig converts parsed .env values into a Config struct,
// stripping TF_VAR_ prefixes to get TF variable names.
func (e *DotEnvFile) ToConfig() *Config {
cfg := &Config{
Variables: make(map[string]string),
}
for k, v := range e.Values {
name := strings.TrimPrefix(k, "TF_VAR_")
cfg.Variables[name] = v
}
return cfg
}
// stripInlineComment removes inline comments from a value.
// Handles both quoted and unquoted values.
func stripInlineComment(val string) string {
// If value starts with a quote, find the closing quote first
if len(val) > 0 && (val[0] == '"' || val[0] == '\'') {
quote := val[0]
for i := 1; i < len(val); i++ {
if val[i] == quote {
// Everything after closing quote is inline comment
rest := strings.TrimSpace(val[i+1:])
if strings.HasPrefix(rest, "#") {
return val[:i+1]
}
return val
}
}
// Unclosed quote — return as-is
return val
}
// Unquoted: find first # preceded by space
for i := 0; i < len(val); i++ {
if val[i] == '#' && (i == 0 || val[i-1] == ' ') {
return strings.TrimSpace(val[:i])
}
}
return val
}
// unquoteValue removes surrounding quotes from a value.
func unquoteValue(val string) string {
if len(val) >= 2 {
if (val[0] == '"' && val[len(val)-1] == '"') ||
(val[0] == '\'' && val[len(val)-1] == '\'') {
return val[1 : len(val)-1]
}
}
return val
}
// StripTFVarPrefix removes the TF_VAR_ prefix from an environment variable name.
// Returns the name unchanged if it doesn't have the prefix.
func StripTFVarPrefix(name string) string {
return strings.TrimPrefix(name, "TF_VAR_")
}