- 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
177 lines
4.2 KiB
Go
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_")
|
|
}
|