- 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
252 lines
5.6 KiB
Go
252 lines
5.6 KiB
Go
package config
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// WriteTfVars generates a terraform.tfvars file from a Config.
|
|
// The output is valid HCL syntax for Terraform variable files.
|
|
func WriteTfVars(cfg *Config, path string) error {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return fmt.Errorf("creating tfvars file %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
w := bufio.NewWriter(f)
|
|
defer w.Flush()
|
|
|
|
// Write header
|
|
header := `# OpenBoatmobile Terraform Variables
|
|
# Generated by obm CLI
|
|
# Values set here can be overridden by environment variables (TF_VAR_<name>)
|
|
`
|
|
fmt.Fprint(w, header)
|
|
|
|
// Write variables by group
|
|
groups := VarsByGroup()
|
|
groupOrder := []VarGroup{
|
|
GroupProvider, GroupProviderHetzner, GroupProviderDO,
|
|
GroupServer, GroupSSH,
|
|
GroupAPIKeys, GroupModel,
|
|
GroupDiscord, GroupTailscale,
|
|
GroupHermes, GroupOpenClaw,
|
|
GroupSecurity, GroupProject,
|
|
}
|
|
|
|
writtenVars := make(map[string]bool)
|
|
|
|
for _, group := range groupOrder {
|
|
vars := groups[group]
|
|
if len(vars) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Write group header
|
|
fmt.Fprintf(w, "\n# ===%s===\n", strings.Repeat("=", 73-len(string(group))-len("===")))
|
|
fmt.Fprintf(w, "# %s\n", group)
|
|
fmt.Fprintf(w, "# %s\n", strings.Repeat("-", len(group)))
|
|
fmt.Fprintln(w)
|
|
|
|
for _, v := range vars {
|
|
if writtenVars[v.Name] {
|
|
continue
|
|
}
|
|
writtenVars[v.Name] = true
|
|
writeTfVarsVar(w, v, cfg)
|
|
}
|
|
}
|
|
|
|
// Write any custom variables not in schema
|
|
customVars := []string{}
|
|
for name := range cfg.Variables {
|
|
if !writtenVars[name] {
|
|
customVars = append(customVars, name)
|
|
}
|
|
}
|
|
if len(customVars) > 0 {
|
|
fmt.Fprint(w, "\n# === Custom Variables ===\n")
|
|
sort.Strings(customVars)
|
|
for _, name := range customVars {
|
|
val := cfg.Variables[name]
|
|
fmt.Fprintf(w, "%s = %s\n\n", name, formatTfVarsValue(val))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// writeTfVarsVar writes a single variable to the tfvars file.
|
|
func writeTfVarsVar(w *bufio.Writer, v VarDef, cfg *Config) {
|
|
val, ok := cfg.GetValue(v.Name)
|
|
if !ok {
|
|
val = v.Default
|
|
}
|
|
|
|
// Write description comment
|
|
if v.Description != "" {
|
|
fmt.Fprintf(w, "# %s\n", v.Description)
|
|
}
|
|
|
|
// Mark required/sensitive
|
|
requirements := []string{}
|
|
if v.Required {
|
|
requirements = append(requirements, "required")
|
|
}
|
|
if v.Sensitive {
|
|
requirements = append(requirements, "sensitive: set via TF_VAR_"+v.Name)
|
|
}
|
|
if len(requirements) > 0 {
|
|
fmt.Fprintf(w, "# %s\n", strings.Join(requirements, ", "))
|
|
}
|
|
|
|
// For sensitive empty values, show placeholder
|
|
displayVal := val
|
|
if v.Sensitive && val == "" {
|
|
displayVal = "" // Will output as ""
|
|
fmt.Fprintf(w, "%s = \"\"\n", v.Name)
|
|
} else {
|
|
fmt.Fprintf(w, "%s = %s\n", v.Name, formatTfVarsValue(displayVal))
|
|
}
|
|
|
|
fmt.Fprintln(w) // Blank line after
|
|
}
|
|
|
|
// formatTfVarsValue formats a value for HCL/terraform.tfvars output.
|
|
func formatTfVarsValue(val string) string {
|
|
if val == "" {
|
|
return "\"\""
|
|
}
|
|
|
|
// Lists: keep as-is if already in HCL format
|
|
if strings.HasPrefix(val, "[") && strings.HasSuffix(val, "]") {
|
|
return val
|
|
}
|
|
|
|
// Booleans: return as-is (true/false)
|
|
if val == "true" || val == "false" {
|
|
return val
|
|
}
|
|
|
|
// Numbers: return as-is if numeric
|
|
if isNumeric(val) {
|
|
return val
|
|
}
|
|
|
|
// Strings: quote
|
|
return fmt.Sprintf("\"%s\"", escapeHCLString(val))
|
|
}
|
|
|
|
// escapeHCLString escapes special characters for HCL strings.
|
|
func escapeHCLString(s string) string {
|
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
|
s = strings.ReplaceAll(s, "\"", "\\\"")
|
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
|
s = strings.ReplaceAll(s, "\r", "\\r")
|
|
s = strings.ReplaceAll(s, "\t", "\\t")
|
|
return s
|
|
}
|
|
|
|
// isNumeric checks if a string represents a number.
|
|
func isNumeric(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if (c < '0' || c > '9') && c != '-' && c != '.' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ParseTfVars reads a terraform.tfvars file and returns a Config.
|
|
// This is a simple parser that handles the common cases:
|
|
// - key = "value"
|
|
// - key = value (number/bool)
|
|
// - key = ["list", "values"]
|
|
// - # comments
|
|
func ParseTfVars(path string) (*Config, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening tfvars file %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
cfg := &Config{
|
|
Variables: make(map[string]string),
|
|
}
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
// Skip empty lines and comments
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
// Parse key = value
|
|
idx := strings.Index(line, "=")
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(line[:idx])
|
|
val := strings.TrimSpace(line[idx+1:])
|
|
|
|
// Parse value
|
|
val = parseTfVarsValue(val)
|
|
|
|
cfg.Variables[key] = val
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("reading tfvars file %s: %w", path, err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// parseTfVarsValue extracts the value from an HCL assignment.
|
|
func parseTfVarsValue(val string) string {
|
|
val = strings.TrimSpace(val)
|
|
|
|
// Boolean
|
|
if val == "true" || val == "false" {
|
|
return val
|
|
}
|
|
|
|
// Number
|
|
if isNumeric(val) {
|
|
return val
|
|
}
|
|
|
|
// Quoted string
|
|
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
|
|
return unescapeHCLString(val[1 : len(val)-1])
|
|
}
|
|
|
|
// List
|
|
if strings.HasPrefix(val, "[") {
|
|
// Return as-is for lists (user needs to handle the format)
|
|
return val
|
|
}
|
|
|
|
// Unknown: return as-is
|
|
return val
|
|
}
|
|
|
|
// unescapeHCLString reverses HCL string escaping.
|
|
func unescapeHCLString(s string) string {
|
|
s = strings.ReplaceAll(s, "\\\"", "\"")
|
|
s = strings.ReplaceAll(s, "\\\\", "\\")
|
|
s = strings.ReplaceAll(s, "\\n", "\n")
|
|
s = strings.ReplaceAll(s, "\\r", "\r")
|
|
s = strings.ReplaceAll(s, "\\t", "\t")
|
|
return s
|
|
}
|