- Enhanced Makefile with cross-compilation for linux/amd64, linux/arm64, darwin/arm64, windows/amd64, windows/arm64 - Added GitHub Actions CI workflow for testing on all platforms - Added GitHub Actions Release workflow triggered by version tags - Added VERSION file for version tracking - Added scripts/release.sh for automated release process - Added Dockerfile for containerized builds - Added CONTRIBUTING.md with release process documentation - Added CHANGELOG.md for version tracking - Updated .gitignore to exclude build artifacts - Fixed unused variable in cmd/obm/main.go - Version now injected via ldflags (main.version, main.gitCommit, main.buildTime)
476 lines
15 KiB
Go
476 lines
15 KiB
Go
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, "<config>")
|
|
}
|
|
|
|
// 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 != "<config>" {
|
|
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, ", "))
|
|
}
|