package deploy import ( "fmt" "os" "strings" "github.com/openboatmobile/obm/internal/config" "github.com/openboatmobile/obm/internal/prompt" ) // Run executes the interactive deploy walkthrough. func Run() error { cfg := &config.DeploymentConfig{} // Interactive mode - use prompt package for user input return runInteractive(cfg) } // RunFromFile executes deployment from a YAML config file (non-interactive mode). // This is designed for CI/CD pipelines where all configuration is predefined. func RunFromFile(configPath string) error { cfg, err := config.LoadYAMLConfig(configPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } deployCfg := cfg.ToDeploymentConfig() // Non-interactive mode - write config and proceed return writeConfig(deployCfg, configPath) } // RunWithConfig executes deployment with a pre-built DeploymentConfig. // This is useful for programmatic usage where config is constructed elsewhere. func RunWithConfig(cfg *config.DeploymentConfig) error { return writeConfig(cfg, "") } // runInteractive handles the interactive wizard flow. func runInteractive(cfg *config.DeploymentConfig) error { prompt.Header("🚢 OpenBoatmobile — Deploy your AI agent") stepFramework(cfg) stepCloudProvider(cfg) stepProviderToken(cfg) stepSSHKey(cfg) stepServerConfig(cfg) stepInferenceProvider(cfg) stepTailscale(cfg) stepDiscord(cfg) stepSummaryAndWrite(cfg) return nil } func stepFramework(cfg *config.DeploymentConfig) { prompt.StepHeader(1, "Agent Framework") idx, err := prompt.Select("Choose your agent framework:", []string{ "Hermes Agent (Nous Research) — Python-based, highly configurable", "OpenClaw — Node.js-based, simpler setup", }) if err != nil { prompt.Error(err.Error()) return } cfg.Framework = []string{"hermes", "openclaw"}[idx-1] prompt.Success(fmt.Sprintf("Selected: %s", cfg.Framework)) if cfg.Framework == "openclaw" { cfg.OpenClawVersion = "lts" cfg.NodeVersion = "22" cfg.EnableSwap = true cfg.SwapSizeGB = 2 cfg.EnableFail2ban = true cfg.EnableUnattendedUpgrades = true } if cfg.Framework == "hermes" { cfg.DockerEnabled = true cfg.VeniceBaseURL = "https://api.venice.ai/api/v1" cfg.GatewayAllowAllUsers = true cfg.DiscordAutoThread = true } } func stepCloudProvider(cfg *config.DeploymentConfig) { prompt.StepHeader(2, "Cloud Provider") idx, err := prompt.Select("Choose your cloud provider:", []string{ "Hetzner Cloud — from €4.49/mo (recommended, ~70% cheaper)", "DigitalOcean — from $6/mo (wider region availability)", }) if err != nil { prompt.Error(err.Error()) return } cfg.CloudProvider = []string{"hetzner", "digitalocean"}[idx-1] prompt.Success(fmt.Sprintf("Selected: %s", cfg.CloudProvider)) } func stepProviderToken(cfg *config.DeploymentConfig) { prompt.StepHeader(3, "Provider API Token") switch cfg.CloudProvider { case "hetzner": prompt.Info("Get yours at: https://console.hetzner.cloud/ → Security → API Tokens") cfg.HetznerToken = prompt.Password("Hetzner API token") if cfg.HetznerToken != "" { prompt.Success("Token saved (will be validated in a future step)") } case "digitalocean": prompt.Info("Get yours at: https://cloud.digitalocean.com/account/api/tokens") cfg.DOToken = prompt.Password("DigitalOcean API token") if cfg.DOToken != "" { prompt.Success("Token saved (will be validated in a future step)") } } } func stepSSHKey(cfg *config.DeploymentConfig) { prompt.StepHeader(4, "SSH Key") if cfg.CloudProvider == "hetzner" { prompt.Info("Enter the name of your SSH key as shown in Hetzner Cloud Console") name := prompt.Input("SSH key name", "") if name != "" { cfg.SSHKeyNames = []string{name} prompt.Success(fmt.Sprintf("SSH key: %s", name)) } } else { prompt.Info("Enter the fingerprint of your SSH key from DigitalOcean") fp := prompt.Input("SSH key fingerprint", "") if fp != "" { cfg.SSHKeyFingerprints = []string{fp} prompt.Success(fmt.Sprintf("SSH key fingerprint: %s", prompt.MaskValue(fp))) } } } func stepServerConfig(cfg *config.DeploymentConfig) { prompt.StepHeader(5, "Server Configuration") cfg.ServerName = prompt.Input("Server name", "agent-gateway") if cfg.CloudProvider == "hetzner" { idx, _ := prompt.SelectWithDefault("Location:", []string{ "ash — Ashburn, VA (US East)", "fsn1 — Falkenstein (EU Central)", "nbg1 — Nuremberg (EU Central)", "hel1 — Helsinki (EU North)", }, 1) cfg.Location = []string{"ash", "fsn1", "nbg1", "hel1"}[idx-1] idx, _ = prompt.SelectWithDefault("Server type:", []string{ "cpx21 — 3 vCPU, 4 GB RAM, 80 GB (€4.49/mo) — recommended", "cx23 — 2 vCPU, 4 GB RAM, 80 GB (€5.83/mo)", "cpx31 — 4 vCPU, 8 GB RAM, 80 GB (€8.98/mo)", }, 1) cfg.ServerType = []string{"cpx21", "cx23", "cpx31"}[idx-1] } else { idx, _ := prompt.SelectWithDefault("Region:", []string{ "nyc3 — New York (US East)", "sfo2 — San Francisco (US West)", "ams3 — Amsterdam (EU)", "lon1 — London (EU)", "sgp1 — Singapore (AP)", }, 1) cfg.Region = []string{"nyc3", "sfo2", "ams3", "lon1", "sgp1"}[idx-1] idx, _ = prompt.SelectWithDefault("Droplet size:", []string{ "s-2vcpu-4gb — 2 vCPU, 4 GB RAM ($24/mo)", "s-4vcpu-8gb — 4 vCPU, 8 GB RAM ($48/mo)", }, 1) cfg.DropletSize = []string{"s-2vcpu-4gb", "s-4vcpu-8gb"}[idx-1] } cfg.AgentName = prompt.Input("Agent name", cfg.Framework) cfg.AgentTimezone = prompt.Input("Timezone", "UTC") if cfg.Framework == "hermes" { cfg.DockerEnabled = prompt.Confirm("Use Docker deployment?", true) } } func stepInferenceProvider(cfg *config.DeploymentConfig) { prompt.StepHeader(6, "Inference Provider") idx, err := prompt.Select("Primary inference provider:", []string{ "Venice AI — uncensored, privacy-focused (recommended)", "OpenRouter — aggregator with many models", "OpenAI — GPT-4o, o1, etc.", "Anthropic — Claude models", "Custom — OpenAI-compatible endpoint", }) if err != nil { prompt.Error(err.Error()) return } providers := []string{"venice", "openrouter", "openai", "anthropic", "custom"} cfg.InferenceProvider = providers[idx-1] switch cfg.InferenceProvider { case "venice": prompt.Info("Get your key at: https://venice.ai → Settings → API Keys") cfg.InferenceAPIKey = prompt.Password("Venice AI API key") cfg.InferenceBaseURL = "https://api.venice.ai/api/v1" cfg.VeniceBaseURL = cfg.InferenceBaseURL prompt.Success("Venice AI key saved") case "openrouter": prompt.Info("Get your key at: https://openrouter.ai/keys") cfg.InferenceAPIKey = prompt.Password("OpenRouter API key") cfg.InferenceBaseURL = "https://openrouter.ai/api/v1" prompt.Success("OpenRouter key saved") case "openai": cfg.InferenceAPIKey = prompt.Password("OpenAI API key") cfg.InferenceBaseURL = "https://api.openai.com/v1" prompt.Success("OpenAI key saved") case "anthropic": cfg.InferenceAPIKey = prompt.Password("Anthropic API key") cfg.InferenceBaseURL = "https://api.anthropic.com" prompt.Success("Anthropic key saved") case "custom": cfg.InferenceBaseURL = prompt.Input("Base URL", "") cfg.InferenceAPIKey = prompt.Password("API key") prompt.Success("Custom provider configured") } // Model selection prompt.Info("Enter model ID (e.g. zai-org-glm-5, gpt-4o, claude-sonnet-4)") cfg.PrimaryModel = prompt.Input("Primary model", defaultModel(cfg.InferenceProvider)) if cfg.PrimaryModel != "" { prompt.Success(fmt.Sprintf("Primary model: %s", cfg.PrimaryModel)) } // Fallback models if prompt.Confirm("Add fallback models?", false) { for { fb := prompt.Input("Fallback model ID (blank to stop)", "") if fb == "" { break } cfg.FallbackModels = append(cfg.FallbackModels, fb) } if len(cfg.FallbackModels) > 0 { prompt.Success(fmt.Sprintf("Fallback models: %s", strings.Join(cfg.FallbackModels, ", "))) } } } func stepTailscale(cfg *config.DeploymentConfig) { prompt.StepHeader(7, "Remote Access") cfg.EnableTailscale = prompt.Confirm("Install Tailscale for secure remote access? (recommended)", true) if cfg.EnableTailscale { prompt.Info("Get your key at: https://login.tailscale.com/admin/settings/keys") cfg.TailscaleAuthKey = prompt.Password("Tailscale auth key") cfg.TailnetDomain = prompt.Input("Tailnet domain", "tailnet") prompt.Success("Tailscale configured") } else { prompt.Warn("Without Tailscale, you'll need SSH or another method for remote access") } } func stepDiscord(cfg *config.DeploymentConfig) { prompt.StepHeader(8, "Discord Integration") cfg.EnableDiscord = prompt.Confirm("Connect to Discord?", false) if !cfg.EnableDiscord { return } prompt.Info("Create a bot at: https://discord.com/developers/applications") cfg.DiscordBotToken = prompt.Password("Discord bot token") cfg.DiscordServerID = prompt.Input("Server/guild ID", "") // User IDs for { uid := prompt.Input("Discord user ID (blank to stop)", "") if uid == "" { break } cfg.DiscordUserIDs = append(cfg.DiscordUserIDs, uid) } if cfg.Framework == "hermes" { cfg.DiscordHomeChannel = prompt.Input("Home channel ID", "") cfg.DiscordAutoThread = prompt.Confirm("Auto-create threads on @mention?", true) } prompt.Success("Discord configured") } func stepSummaryAndWrite(cfg *config.DeploymentConfig) { prompt.Divider() prompt.Header("Configuration Summary") prompt.Divider() prompt.SummaryLine("Framework", cfg.Framework) prompt.SummaryLine("Provider", fmt.Sprintf("%s (%s)", cfg.CloudProvider, config.LocationOrRegion(cfg))) prompt.SummaryLine("Server", fmt.Sprintf("%s — %s", config.ServerTypeOrDroplet(cfg), cfg.MonthlyCostEstimate())) prompt.SummaryLine("SSH Key", config.SSHKeySummary(cfg)) prompt.SummaryLine("Inference", fmt.Sprintf("%s → %s", cfg.InferenceProvider, cfg.PrimaryModel)) if len(cfg.FallbackModels) > 0 { prompt.SummaryLine("Fallbacks", strings.Join(cfg.FallbackModels, ", ")) } prompt.SummaryLine("Tailscale", boolStr(cfg.EnableTailscale)) prompt.SummaryLine("Discord", boolStr(cfg.EnableDiscord)) if cfg.BraveSearchAPIKey != "" { prompt.SummaryLine("Brave Search", "configured") } prompt.Divider() if !prompt.Confirm("Write .env file?", true) { prompt.Warn("Aborted — no files written") return } // Write the config if err := writeConfig(cfg, ".env"); err != nil { prompt.Error(err.Error()) return } prompt.Success(".env file written") if prompt.Confirm("Run terraform init && terraform apply?", false) { prompt.Info("Terraform integration coming soon — for now, run manually:") fmt.Printf(" source .env && terraform init && terraform apply\n") } } // writeConfig writes the deployment configuration to a .env file. // For non-interactive mode, sourceFile is used for error messages. func writeConfig(cfg *config.DeploymentConfig, sourceFile string) error { envContent := buildEnvFile(cfg) // Write to .env file outputPath := ".env" if err := os.WriteFile(outputPath, []byte(envContent), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", outputPath, err) } // Print summary for non-interactive mode if sourceFile != ".env" && sourceFile != "" { fmt.Printf("Configuration loaded from: %s\n", sourceFile) } fmt.Printf("Configuration written to: %s\n", outputPath) return nil } // Helpers func defaultModel(provider string) string { defaults := map[string]string{ "venice": "zai-org-glm-5", "openrouter": "openai/gpt-4o", "openai": "gpt-4o", "anthropic": "claude-sonnet-4", "custom": "", } return defaults[provider] } func boolStr(b bool) string { if b { return "Enabled" } return "Disabled" } func buildEnvFile(cfg *config.DeploymentConfig) string { var b strings.Builder b.WriteString("# Generated by obm\n") b.WriteString(fmt.Sprintf("# Framework: %s | Provider: %s\n\n", cfg.Framework, cfg.CloudProvider)) b.WriteString(fmt.Sprintf("TF_VAR_cloud_provider=%s\n", cfg.CloudProvider)) b.WriteString(fmt.Sprintf("TF_VAR_agent_framework=%s\n", cfg.Framework)) // Provider token switch cfg.CloudProvider { case "hetzner": b.WriteString(fmt.Sprintf("TF_VAR_hcloud_token=%s\n", cfg.HetznerToken)) case "digitalocean": b.WriteString(fmt.Sprintf("TF_VAR_do_token=%s\n", cfg.DOToken)) } // SSH keys if len(cfg.SSHKeyNames) > 0 { b.WriteString(fmt.Sprintf("TF_VAR_ssh_key_names='%s'\n", formatJSONArray(cfg.SSHKeyNames))) } if len(cfg.SSHKeyFingerprints) > 0 { b.WriteString(fmt.Sprintf("TF_VAR_ssh_key_fingerprints='%s'\n", formatJSONArray(cfg.SSHKeyFingerprints))) } // Server config b.WriteString(fmt.Sprintf("TF_VAR_server_name=%s\n", cfg.ServerName)) b.WriteString(fmt.Sprintf("TF_VAR_agent_name=%s\n", cfg.AgentName)) b.WriteString(fmt.Sprintf("TF_VAR_agent_timezone=%s\n", cfg.AgentTimezone)) if cfg.CloudProvider == "hetzner" { b.WriteString(fmt.Sprintf("TF_VAR_location_hetzner=%s\n", cfg.Location)) b.WriteString(fmt.Sprintf("TF_VAR_server_type_hetzner=%s\n", cfg.ServerType)) } if cfg.CloudProvider == "digitalocean" { b.WriteString(fmt.Sprintf("TF_VAR_region_digitalocean=%s\n", cfg.Region)) b.WriteString(fmt.Sprintf("TF_VAR_droplet_size_digitalocean=%s\n", cfg.DropletSize)) } // Inference switch cfg.InferenceProvider { case "venice": b.WriteString(fmt.Sprintf("TF_VAR_venice_api_key=%s\n", cfg.InferenceAPIKey)) if cfg.VeniceBaseURL != "" { b.WriteString(fmt.Sprintf("TF_VAR_venice_base_url=%s\n", cfg.VeniceBaseURL)) } case "openrouter": b.WriteString(fmt.Sprintf("TF_VAR_openrouter_api_key=%s\n", cfg.InferenceAPIKey)) case "openai": b.WriteString(fmt.Sprintf("TF_VAR_openai_api_key=%s\n", cfg.InferenceAPIKey)) case "anthropic": b.WriteString(fmt.Sprintf("TF_VAR_anthropic_api_key=%s\n", cfg.InferenceAPIKey)) } // Models if cfg.PrimaryModel != "" { b.WriteString(fmt.Sprintf("TF_VAR_primary_model=%s\n", cfg.PrimaryModel)) } if len(cfg.FallbackModels) > 0 { b.WriteString(fmt.Sprintf("TF_VAR_fallback_models='%s'\n", formatJSONArray(cfg.FallbackModels))) } // Hermes-specific if cfg.Framework == "hermes" { b.WriteString(fmt.Sprintf("TF_VAR_docker_enabled=%v\n", cfg.DockerEnabled)) } // OpenClaw-specific if cfg.Framework == "openclaw" { b.WriteString(fmt.Sprintf("TF_VAR_openclaw_version=%s\n", cfg.OpenClawVersion)) b.WriteString(fmt.Sprintf("TF_VAR_node_version=%s\n", cfg.NodeVersion)) } // Tailscale if cfg.EnableTailscale { b.WriteString("TF_VAR_enable_tailscale=true\n") b.WriteString(fmt.Sprintf("TF_VAR_tailscale_auth_key=%s\n", cfg.TailscaleAuthKey)) b.WriteString(fmt.Sprintf("TF_VAR_tailscale_tailnet_domain=%s\n", cfg.TailnetDomain)) } // Discord if cfg.EnableDiscord { b.WriteString(fmt.Sprintf("TF_VAR_discord_bot_token=%s\n", cfg.DiscordBotToken)) b.WriteString(fmt.Sprintf("TF_VAR_discord_server_id=%s\n", cfg.DiscordServerID)) if len(cfg.DiscordUserIDs) > 0 { b.WriteString(fmt.Sprintf("TF_VAR_discord_user_id='%s'\n", formatJSONArray(cfg.DiscordUserIDs))) } } // Optional if cfg.BraveSearchAPIKey != "" { b.WriteString(fmt.Sprintf("TF_VAR_brave_search_api_key=%s\n", cfg.BraveSearchAPIKey)) } return b.String() } func formatJSONArray(items []string) string { quoted := make([]string, len(items)) for i, item := range items { quoted[i] = fmt.Sprintf(`"%s"`, item) } return fmt.Sprintf("[%s]", strings.Join(quoted, ", ")) }