refactor(onboard): simplify to DB + keys only, delegate setup to web UI

Remove the 700+ line interactive wizard (provider, model, channels, TTS,
features) since the web UI setup wizard already handles all of this.

The onboard command now only: prompts for Postgres DSN, tests connection,
generates gateway token + encryption key, runs migrations, seeds placeholder
providers, and saves .env.local with the 3 required secrets.

Delete 8 helper files: onboard_auto, onboard_verify, onboard_models,
onboard_resolve, onboard_claude_cli, onboard_tts, onboard_feishu, onboard_zalo.
This commit is contained in:
viettranx committed 2026-03-09 20:01:05 +07:00
1 parent a1db0fb666
commit 3187196bd8
11 files changed
+130 -1920

No files matched your search

+104 -660
View File
@@ -1,595 +1,63 @@
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
func onboardCmd() *cobra.Command {
return &cobra.Command{
Use: "onboard",
Short: "Interactive setup wizard — configure provider, model, gateway, channels",
Short: "Quick setup — configure database, generate keys, run migrations",
Run: func(cmd *cobra.Command, args []string) {
runOnboard()
},
}
}
type providerInfo struct {
name string
envKey string
modelHint string
}
var providerMap = map[string]providerInfo{
"openrouter": {"openrouter", "GOCLAW_OPENROUTER_API_KEY", "anthropic/claude-sonnet-4-5-20250929"},
"anthropic": {"anthropic", "GOCLAW_ANTHROPIC_API_KEY", "claude-sonnet-4-5-20250929"},
"openai": {"openai", "GOCLAW_OPENAI_API_KEY", "gpt-4o"},
"groq": {"groq", "GOCLAW_GROQ_API_KEY", "llama-3.3-70b-versatile"},
"deepseek": {"deepseek", "GOCLAW_DEEPSEEK_API_KEY", "deepseek-chat"},
"gemini": {"gemini", "GOCLAW_GEMINI_API_KEY", "gemini-2.0-flash"},
"mistral": {"mistral", "GOCLAW_MISTRAL_API_KEY", "mistral-large-latest"},
"xai": {"xai", "GOCLAW_XAI_API_KEY", "grok-3-mini"},
"minimax": {"minimax", "GOCLAW_MINIMAX_API_KEY", "MiniMax-M2.5"},
"cohere": {"cohere", "GOCLAW_COHERE_API_KEY", "command-a"},
"perplexity": {"perplexity", "GOCLAW_PERPLEXITY_API_KEY", "sonar-pro"},
"claude_cli": {"claude-cli", "", "sonnet"},
"custom": {"custom", "", ""},
}
func runOnboard() {
// If env vars provide API keys, skip interactive wizard entirely.
if canAutoOnboard() {
cfgPath := resolveConfigPath()
fmt.Println("Environment variables detected. Running non-interactive setup...")
if runAutoOnboard(cfgPath) {
return
}
fmt.Println("Auto-onboard failed, falling through to interactive wizard...")
fmt.Println()
}
fmt.Println("╔══════════════════════════════════════════════╗")
fmt.Println("║ GoClaw — Setup Wizard ║")
fmt.Println("║ GoClaw — Quick Setup ║")
fmt.Println("╚══════════════════════════════════════════════╝")
fmt.Println()
// Determine config path
cfgPath := resolveConfigPath()
cfg := config.Default()
// Check existing config
var cfg *config.Config
isNewConfig := true
// Load existing config if present (preserve gateway port, etc.)
if _, err := os.Stat(cfgPath); err == nil {
fmt.Printf("Found existing config at %s\n", cfgPath)
useExisting, err := promptConfirm("Use existing config as base?", true)
if err != nil {
fmt.Println("Cancelled.")
return
}
if useExisting {
loaded, err := config.Load(cfgPath)
if err != nil {
fmt.Printf("Warning: could not load existing config: %v\n", err)
cfg = config.Default()
} else {
cfg = loaded
isNewConfig = false
}
} else {
cfg = config.Default()
}
} else {
cfg = config.Default()
}
// --- Declare all wizard variables (pre-filled from existing config) ---
var (
providerChoice = cfg.Agents.Defaults.Provider
apiKey string
customAPIBase string
customModel string
modelChoice = cfg.Agents.Defaults.Model
orModelChoice = cfg.Agents.Defaults.Model
portStr = strconv.Itoa(cfg.Gateway.Port)
selectedChannels []string
telegramToken string
zaloToken string
zaloDMPolicy = "pairing"
feishuAppID string
feishuSecret string
feishuDomain = "lark"
feishuConnMode = "websocket"
selectedFeatures []string
embProvider string
ttsProvider = "none"
ttsAPIKey string
ttsGroupID string
ttsAutoMode = "off"
postgresDSN string
traceVerbose bool
)
if isNewConfig {
providerChoice = "openrouter"
selectedFeatures = []string{"memory", "browser"}
}
if portStr == "0" {
portStr = "18790"
}
// Pre-fill from existing config
if cfg.Channels.Telegram.Enabled {
selectedChannels = append(selectedChannels, "telegram")
telegramToken = cfg.Channels.Telegram.Token
}
if cfg.Channels.Zalo.Enabled {
selectedChannels = append(selectedChannels, "zalo")
zaloToken = cfg.Channels.Zalo.Token
zaloDMPolicy = cfg.Channels.Zalo.DMPolicy
}
if cfg.Channels.Feishu.Enabled {
selectedChannels = append(selectedChannels, "feishu")
feishuAppID = cfg.Channels.Feishu.AppID
feishuSecret = cfg.Channels.Feishu.AppSecret
feishuDomain = cfg.Channels.Feishu.Domain
feishuConnMode = cfg.Channels.Feishu.ConnectionMode
}
if cfg.Agents.Defaults.Memory != nil && (cfg.Agents.Defaults.Memory.Enabled == nil || *cfg.Agents.Defaults.Memory.Enabled) {
selectedFeatures = append(selectedFeatures, "memory")
embProvider = cfg.Agents.Defaults.Memory.EmbeddingProvider
}
if cfg.Tools.Browser.Enabled {
selectedFeatures = append(selectedFeatures, "browser")
}
if cfg.Tts.Provider != "" {
ttsProvider = cfg.Tts.Provider
ttsAutoMode = cfg.Tts.Auto
ttsAPIKey = cfg.Tts.OpenAI.APIKey
if ttsAPIKey == "" {
ttsAPIKey = cfg.Tts.ElevenLabs.APIKey
}
if ttsAPIKey == "" {
ttsAPIKey = cfg.Tts.MiniMax.APIKey
}
ttsGroupID = cfg.Tts.MiniMax.GroupID
}
postgresDSN = cfg.Database.PostgresDSN
// Pre-fill API key from env or config
apiKey = resolveExistingAPIKey(cfg, providerChoice)
if cfg.Providers.OpenAI.APIBase != "" {
customAPIBase = cfg.Providers.OpenAI.APIBase
}
// ── Step 1: Provider ──
providerOptions := []SelectOption[string]{
{"OpenRouter (recommended — access to many models)", "openrouter"},
{"Anthropic (Claude models directly)", "anthropic"},
{"OpenAI (GPT models)", "openai"},
{"Groq (fast inference)", "groq"},
{"DeepSeek (DeepSeek models)", "deepseek"},
{"Gemini (Google Gemini)", "gemini"},
{"Mistral (Mistral AI models)", "mistral"},
{"xAI (Grok models)", "xai"},
{"MiniMax (MiniMax models)", "minimax"},
{"Cohere (Command models)", "cohere"},
{"Perplexity (Sonar search models)", "perplexity"},
{"Claude CLI (use local claude CLI — no API key needed)", "claude_cli"},
{"Custom (any OpenAI-compatible endpoint)", "custom"},
}
var err error
providerChoice, err = promptSelect("Step 1 · AI Provider — Choose your LLM provider", providerOptions, 0)
if err != nil {
fmt.Println("Cancelled.")
return
}
// Claude CLI variables
var cliPath, cliModel string
if cfg.Providers.ClaudeCLI.CLIPath != "" {
cliPath = cfg.Providers.ClaudeCLI.CLIPath
cliModel = cfg.Providers.ClaudeCLI.Model
}
// Provider-specific prompts
switch providerChoice {
case "claude_cli":
if cliPath == "" {
cliPath = "claude"
}
cliPath, err = promptString("Claude CLI Path", "Path to the claude binary", cliPath)
if err != nil {
fmt.Println("Cancelled.")
return
}
// Verify CLI binary exists
if _, lookErr := exec.LookPath(cliPath); lookErr != nil {
fmt.Printf(" ✗ Claude CLI binary not found at %q\n", cliPath)
fmt.Println(" Install Claude CLI first: https://docs.anthropic.com/en/docs/claude-cli")
return
}
// Check authentication status
fmt.Print(" Checking Claude CLI authentication... ")
authStatus, authErr := providers.CheckClaudeAuthStatus(context.Background(), cliPath)
if authErr != nil || !authStatus.LoggedIn {
if authErr != nil {
fmt.Println("FAILED")
fmt.Printf(" %v\n", authErr)
} else {
fmt.Println("NOT LOGGED IN")
}
fmt.Println()
runLogin, confirmErr := promptConfirm("Claude CLI requires authentication. Run login now?", true)
if confirmErr != nil {
fmt.Println("Cancelled.")
return
}
if runLogin {
if loginErr := runClaudeAuthLogin(cliPath); loginErr != nil {
fmt.Printf(" Login failed: %v\n", loginErr)
fmt.Println(" You can try manually: claude auth login")
return
}
} else {
fmt.Println(" Skipping login. Run 'claude auth login' before starting the gateway.")
}
} else {
sub := authStatus.SubscriptionType
if sub == "" {
sub = "unknown"
}
fmt.Printf("OK\n")
fmt.Printf(" ✓ Authenticated as %s (subscription: %s)\n", authStatus.Email, sub)
}
fmt.Println()
if cliModel == "" {
cliModel = "sonnet"
}
cliModel, err = promptString("Default Model", "Model alias: sonnet, opus, haiku", cliModel)
if err != nil {
fmt.Println("Cancelled.")
return
}
case "custom":
customAPIBase, err = promptString("API Base URL", "OpenAI-compatible endpoint (e.g. Ollama, vLLM, LiteLLM)", customAPIBase)
if err != nil {
fmt.Println("Cancelled.")
return
}
apiKey, err = promptPassword("API Key", "Leave empty if not required")
if err != nil {
fmt.Println("Cancelled.")
return
}
customModel, err = promptString("Model ID", "The model to use on this endpoint", customModel)
if err != nil {
fmt.Println("Cancelled.")
return
}
case "openrouter":
apiKey, err = promptPassword("OpenRouter API Key", "Get yours at https://openrouter.ai/keys")
if err != nil {
fmt.Println("Cancelled.")
return
}
// Fetch and select model
fmt.Println(" Fetching OpenRouter models...")
orModelOptions := buildOpenRouterModelOptions()
orModelChoice, err = promptSelect("Choose OpenRouter Model", orModelOptions, 0)
if err != nil {
fmt.Println("Cancelled.")
return
}
default:
// Standard provider
apiKey, err = promptPassword("API Key", "Your provider API key (check env vars or dashboard)")
if err != nil {
fmt.Println("Cancelled.")
return
}
modelChoice, err = promptString("Default Model", "Model ID to use (leave empty for provider default)", modelChoice)
if err != nil {
fmt.Println("Cancelled.")
return
if loaded, err := config.Load(cfgPath); err == nil {
cfg = loaded
}
}
// ── Gateway Port ──
portStr, err = promptString("Gateway Port", "WebSocket server port", portStr)
if err != nil {
fmt.Println("Cancelled.")
return
}
// ── Step 2: Channels ──
selectedChannels, err = promptMultiSelect("Step 2 · Channels (select at least 1)", "Enter numbers to toggle channels", []SelectOption[string]{
{"Telegram", "telegram"},
{"Zalo OA", "zalo"},
{"Feishu / Lark", "feishu"},
}, selectedChannels)
if err != nil {
fmt.Println("Cancelled.")
return
}
// Helper closures
hasChannel := func(ch string) bool {
for _, c := range selectedChannels {
if c == ch {
return true
}
}
return false
}
hasFeature := func(f string) bool {
for _, feat := range selectedFeatures {
if feat == f {
return true
}
}
return false
}
// Channel-specific configs
if hasChannel("telegram") {
telegramToken, err = promptPassword("Telegram Bot Token", "Get from @BotFather on Telegram")
if err != nil {
fmt.Println("Cancelled.")
return
}
if telegramToken == "" {
telegramToken = cfg.Channels.Telegram.Token // keep existing
}
}
if hasChannel("zalo") {
if err := promptZaloConfig(&zaloToken, &zaloDMPolicy); err != nil {
fmt.Println("Cancelled.")
return
}
}
if hasChannel("feishu") {
if err := promptFeishuConfig(&feishuAppID, &feishuSecret, &feishuDomain, &feishuConnMode); err != nil {
fmt.Println("Cancelled.")
return
}
}
// ── Features ──
selectedFeatures, err = promptMultiSelect("Features (recommended: keep both)", "Enter numbers to toggle features", []SelectOption[string]{
{"Memory (vector search over agent notes)", "memory"},
{"Browser automation (agent can browse the web)", "browser"},
}, selectedFeatures)
if err != nil {
fmt.Println("Cancelled.")
return
}
// Memory embedding provider
if hasFeature("memory") {
embProvider, err = promptSelect("Memory Embedding Provider", []SelectOption[string]{
{"Auto-detect (use chat provider's API key)", ""},
{"OpenAI (text-embedding-3-small)", "openai"},
{"OpenRouter (openai/text-embedding-3-small)", "openrouter"},
{"Gemini (text-embedding-004)", "gemini"},
}, 0)
if err != nil {
fmt.Println("Cancelled.")
return
}
}
// ── TTS ──
if err := promptTTSConfig(&ttsProvider, &ttsAPIKey, &ttsGroupID, &ttsAutoMode); err != nil {
fmt.Println("Cancelled.")
return
}
// ── Verbose Tracing ──
traceVerbose, err = promptConfirm("Enable verbose tracing? (Logs full LLM input in trace spans)", false)
if err != nil {
fmt.Println("Cancelled.")
return
}
// ── Step 3: Database ──
postgresDSN, err = promptString("Step 3 · Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", postgresDSN)
if err != nil {
fmt.Println("Cancelled.")
return
}
// --- Post-form validation ---
var errors []string
if providerChoice == "custom" {
if customAPIBase == "" {
errors = append(errors, "API base URL is required for custom provider")
}
if customModel == "" {
errors = append(errors, "Model ID is required for custom provider")
}
} else if providerChoice == "claude_cli" {
if cliPath == "" {
errors = append(errors, "Claude CLI path is required")
}
} else if apiKey == "" {
errors = append(errors, fmt.Sprintf("API key is required for %s", providerChoice))
}
if _, err := strconv.Atoi(portStr); err != nil {
errors = append(errors, fmt.Sprintf("Invalid gateway port: %s", portStr))
}
if len(selectedChannels) == 0 {
errors = append(errors, "At least one channel must be selected (Telegram, Zalo, or Feishu)")
}
if hasChannel("telegram") && telegramToken == "" {
errors = append(errors, "Telegram bot token is required")
}
if hasChannel("zalo") && zaloToken == "" {
errors = append(errors, "Zalo bot token is required")
}
if hasChannel("feishu") && (feishuAppID == "" || feishuSecret == "") {
errors = append(errors, "Feishu App ID and App Secret are required")
}
// ── Step 1: Postgres DSN ──
postgresDSN := os.Getenv("GOCLAW_POSTGRES_DSN")
if postgresDSN == "" {
errors = append(errors, "Postgres DSN is required (set GOCLAW_POSTGRES_DSN or enter above)")
postgresDSN = cfg.Database.PostgresDSN
}
if len(errors) > 0 {
fmt.Println()
fmt.Println(" Validation errors:")
for _, e := range errors {
fmt.Printf(" • %s\n", e)
if postgresDSN == "" {
var err error
postgresDSN, err = promptString("Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", "")
if err != nil {
fmt.Println("Cancelled.")
return
}
fmt.Println()
fmt.Println(" Please re-run: ./goclaw onboard")
} else {
fmt.Printf(" Using Postgres DSN from environment\n")
}
if postgresDSN == "" {
fmt.Println(" Error: Postgres DSN is required.")
return
}
// --- Apply collected values to config ---
// Provider & model
if providerChoice == "claude_cli" {
cfg.Agents.Defaults.Provider = "claude-cli"
cfg.Agents.Defaults.Model = cliModel
cfg.Providers.ClaudeCLI.CLIPath = cliPath
cfg.Providers.ClaudeCLI.Model = cliModel
} else if providerChoice == "custom" {
cfg.Agents.Defaults.Provider = "openai"
cfg.Providers.OpenAI.APIBase = customAPIBase
cfg.Providers.OpenAI.APIKey = apiKey
cfg.Agents.Defaults.Model = customModel
} else {
pi := providerMap[providerChoice]
cfg.Agents.Defaults.Provider = pi.name
applyProviderAPIKey(cfg, pi.name, apiKey)
if providerChoice == "openrouter" {
if orModelChoice == "__custom__" {
cfg.Agents.Defaults.Model = pi.modelHint
} else {
cfg.Agents.Defaults.Model = orModelChoice
}
} else {
if modelChoice == "" {
modelChoice = pi.modelHint
}
cfg.Agents.Defaults.Model = modelChoice
}
}
// Gateway
cfg.Gateway.Port, _ = strconv.Atoi(portStr)
if cfg.Gateway.Host == "" {
cfg.Gateway.Host = "0.0.0.0"
}
if cfg.Gateway.Token == "" {
cfg.Gateway.Token = onboardGenerateToken(16)
fmt.Printf(" Generated gateway token: %s\n", cfg.Gateway.Token)
}
// Workspace (use default, no prompt)
cfg.Agents.Defaults.Workspace = "~/.goclaw/workspace"
expandedWS := config.ExpandHome(cfg.Agents.Defaults.Workspace)
if err := os.MkdirAll(expandedWS, 0755); err != nil {
fmt.Printf("Warning: could not create workspace: %v\n", err)
}
// Channels
cfg.Channels.Telegram.Enabled = hasChannel("telegram")
if cfg.Channels.Telegram.Enabled {
cfg.Channels.Telegram.Token = telegramToken
cfg.Channels.Telegram.DMPolicy = "pairing"
}
cfg.Channels.Zalo.Enabled = hasChannel("zalo")
if cfg.Channels.Zalo.Enabled {
cfg.Channels.Zalo.Token = zaloToken
cfg.Channels.Zalo.DMPolicy = zaloDMPolicy
}
cfg.Channels.Feishu.Enabled = hasChannel("feishu")
if cfg.Channels.Feishu.Enabled {
cfg.Channels.Feishu.AppID = feishuAppID
cfg.Channels.Feishu.AppSecret = feishuSecret
cfg.Channels.Feishu.Domain = feishuDomain
cfg.Channels.Feishu.ConnectionMode = feishuConnMode
}
// Features
if hasFeature("memory") {
enabled := true
if cfg.Agents.Defaults.Memory == nil {
cfg.Agents.Defaults.Memory = &config.MemoryConfig{}
}
cfg.Agents.Defaults.Memory.Enabled = &enabled
cfg.Agents.Defaults.Memory.EmbeddingProvider = embProvider
} else {
disabled := false
cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &disabled}
}
cfg.Tools.Browser.Enabled = hasFeature("browser")
if cfg.Tools.Browser.Enabled {
cfg.Tools.Browser.Headless = true
}
// TTS
if ttsProvider != "none" {
cfg.Tts.Provider = ttsProvider
cfg.Tts.Auto = ttsAutoMode
switch ttsProvider {
case "openai":
cfg.Tts.OpenAI.APIKey = ttsAPIKey
case "elevenlabs":
cfg.Tts.ElevenLabs.APIKey = ttsAPIKey
case "minimax":
cfg.Tts.MiniMax.APIKey = ttsAPIKey
cfg.Tts.MiniMax.GroupID = ttsGroupID
case "edge":
cfg.Tts.Edge.Enabled = true
}
}
// Database
cfg.Database.PostgresDSN = postgresDSN
// Auto-generate encryption key for API keys in DB (if not already set).
if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" {
encKey := onboardGenerateToken(32)
os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
fmt.Printf(" Generated encryption key for API keys (AES-256-GCM)\n")
} else {
fmt.Println(" Using existing GOCLAW_ENCRYPTION_KEY from environment")
}
// ── Step 2: Test connection ──
fmt.Print(" Testing Postgres connection... ")
if err := testPostgresConnection(postgresDSN); err != nil {
fmt.Println("FAILED")
@@ -599,135 +67,111 @@ func runOnboard() {
}
fmt.Println("OK")
runMigrate, err := promptConfirm("Run database migration now?", true)
if err != nil {
fmt.Println("Cancelled.")
return
// ── Step 3: Generate keys ──
gatewayToken := os.Getenv("GOCLAW_GATEWAY_TOKEN")
if gatewayToken == "" {
gatewayToken = cfg.Gateway.Token
}
if runMigrate {
fmt.Println(" Running migration...")
m, err := newMigrator(postgresDSN)
if err != nil {
fmt.Printf(" Migration error: %v\n", err)
generatedToken := false
if gatewayToken == "" {
gatewayToken = onboardGenerateToken(16)
generatedToken = true
}
encryptionKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
generatedEncKey := false
if encryptionKey == "" {
encryptionKey = onboardGenerateToken(32)
generatedEncKey = true
}
os.Setenv("GOCLAW_ENCRYPTION_KEY", encryptionKey)
// ── Step 4: Migrations ──
fmt.Print(" Running migrations... ")
m, err := newMigrator(postgresDSN)
if err != nil {
fmt.Printf("FAILED: %v\n", err)
fmt.Println(" You can run it manually later: ./goclaw migrate up")
} else {
if err := m.Up(); err != nil && err.Error() != "no change" {
fmt.Printf("FAILED: %v\n", err)
fmt.Println(" You can run it manually later: ./goclaw migrate up")
} else {
if err := m.Up(); err != nil && err.Error() != "no change" {
fmt.Printf(" Migration error: %v\n", err)
fmt.Println(" You can run it manually later: ./goclaw migrate up")
} else {
v, _, _ := m.Version()
fmt.Printf(" Migration complete (version: %d)\n", v)
}
m.Close()
}
fmt.Println(" Seeding default agent and provider...")
if err := seedManagedData(postgresDSN, cfg); err != nil {
fmt.Printf(" Seed warning: %v\n", err)
fmt.Println(" You can seed manually via the API after starting the gateway.")
} else {
fmt.Println(" Default agent and provider seeded.")
v, _, _ := m.Version()
fmt.Printf("OK (version: %d)\n", v)
}
m.Close()
}
// --- Save config ---
fmt.Println()
fmt.Println("── Saving Config ──")
fmt.Println()
savedProviders := cfg.Providers
savedGwToken := cfg.Gateway.Token
savedTgToken := cfg.Channels.Telegram.Token
savedZaloToken := cfg.Channels.Zalo.Token
savedFeishuAppSecret := cfg.Channels.Feishu.AppSecret
savedTtsOpenAIKey := cfg.Tts.OpenAI.APIKey
savedTtsElevenLabsKey := cfg.Tts.ElevenLabs.APIKey
savedTtsMiniMaxKey := cfg.Tts.MiniMax.APIKey
// Clear secrets from providers but keep non-secret configs (like ClaudeCLI)
cfg.Providers = config.ProvidersConfig{
ClaudeCLI: savedProviders.ClaudeCLI, // no secrets, safe to save in config
// ── Step 5: Seed placeholder providers for UI ──
fmt.Print(" Seeding placeholder providers... ")
if err := seedOnboardPlaceholders(postgresDSN); err != nil {
fmt.Printf("warning: %v\n", err)
} else {
fmt.Println("OK")
}
cfg.Gateway.Token = ""
cfg.Channels.Telegram.Token = ""
cfg.Channels.Zalo.Token = ""
cfg.Channels.Feishu.AppSecret = ""
cfg.Tts.OpenAI.APIKey = ""
cfg.Tts.ElevenLabs.APIKey = ""
cfg.Tts.MiniMax.APIKey = ""
saveErr := config.Save(cfgPath, cfg)
// ── Step 6: Save config ──
if cfg.Gateway.Host == "" {
cfg.Gateway.Host = "0.0.0.0"
}
if cfg.Gateway.Port == 0 {
cfg.Gateway.Port = 18790
}
cfg.Database.PostgresDSN = "" // secrets go in .env.local, not config
cfg.Gateway.Token = "" // secrets go in .env.local, not config
cfg.Providers = savedProviders
cfg.Gateway.Token = savedGwToken
cfg.Channels.Telegram.Token = savedTgToken
cfg.Channels.Zalo.Token = savedZaloToken
cfg.Channels.Feishu.AppSecret = savedFeishuAppSecret
cfg.Tts.OpenAI.APIKey = savedTtsOpenAIKey
cfg.Tts.ElevenLabs.APIKey = savedTtsElevenLabsKey
cfg.Tts.MiniMax.APIKey = savedTtsMiniMaxKey
if err := saveErr; err != nil {
fmt.Printf("Error saving config: %v\n", err)
if err := config.Save(cfgPath, cfg); err != nil {
fmt.Printf(" Error saving config: %v\n", err)
os.Exit(1)
}
fmt.Printf("Config saved to %s (no secrets)\n", cfgPath)
// ── Step 7: Save .env.local ──
envPath := filepath.Join(filepath.Dir(cfgPath), ".env.local")
pi := providerMap[providerChoice]
onboardWriteEnvFile(envPath, cfg, apiKey, pi.envKey, traceVerbose)
fmt.Printf("Secrets saved to %s\n", envPath)
onboardWriteEnvFile(envPath, postgresDSN, gatewayToken, encryptionKey)
// ── Summary ──
port := strconv.Itoa(cfg.Gateway.Port)
// Summary
fmt.Println()
fmt.Println("╔══════════════════════════════════════════════╗")
fmt.Println("║ Setup Complete! ║")
fmt.Println("║ Setup Complete! ║")
fmt.Println("╚══════════════════════════════════════════════╝")
fmt.Println()
fmt.Printf(" Provider: %s\n", cfg.Agents.Defaults.Provider)
fmt.Printf(" Model: %s\n", cfg.Agents.Defaults.Model)
fmt.Printf(" Gateway: ws://%s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Printf(" Token: %s\n", cfg.Gateway.Token)
fmt.Printf(" Workspace: %s\n", cfg.Agents.Defaults.Workspace)
fmt.Println(" Database: PostgreSQL")
if cfg.Channels.Telegram.Enabled {
fmt.Println(" Telegram: enabled")
}
if cfg.Channels.Zalo.Enabled {
fmt.Println(" Zalo: enabled")
}
if cfg.Channels.Feishu.Enabled {
fmt.Printf(" Feishu: enabled (%s, %s)\n", cfg.Channels.Feishu.Domain, cfg.Channels.Feishu.ConnectionMode)
}
if cfg.Agents.Defaults.Memory != nil && (cfg.Agents.Defaults.Memory.Enabled == nil || *cfg.Agents.Defaults.Memory.Enabled) {
embProv := cfg.Agents.Defaults.Memory.EmbeddingProvider
if embProv == "" {
embProv = "auto-detect"
if generatedToken || generatedEncKey {
fmt.Println("── Generated Secrets (shown once, saved to .env.local) ──")
fmt.Println()
if generatedToken {
fmt.Printf(" Gateway Token: %s\n", gatewayToken)
}
fmt.Printf(" Memory: enabled (embedding: %s)\n", embProv)
} else {
fmt.Println(" Memory: disabled")
}
if cfg.Tools.Browser.RemoteURL != "" {
fmt.Printf(" Browser: enabled (remote: %s)\n", cfg.Tools.Browser.RemoteURL)
} else if cfg.Tools.Browser.Enabled {
fmt.Println(" Browser: enabled (headless)")
} else {
fmt.Println(" Browser: disabled")
}
if cfg.Tts.Provider != "" {
autoMode := cfg.Tts.Auto
if autoMode == "" {
autoMode = "off"
if generatedEncKey {
fmt.Printf(" Encryption Key: %s\n", encryptionKey)
}
fmt.Printf(" TTS: %s (auto: %s)\n", cfg.Tts.Provider, autoMode)
} else {
fmt.Println(" TTS: disabled")
fmt.Println()
fmt.Println(" ⚠ These keys are shown only once. They are saved in:")
fmt.Printf(" → %s\n", envPath)
fmt.Println()
}
fmt.Println("── Files ──")
fmt.Println()
fmt.Println("To start the gateway:")
fmt.Printf(" Config: %s (gateway host/port, no secrets)\n", cfgPath)
fmt.Printf(" Secrets: %s (GOCLAW_POSTGRES_DSN, GOCLAW_GATEWAY_TOKEN, GOCLAW_ENCRYPTION_KEY)\n", envPath)
fmt.Println()
fmt.Printf(" source %s && ./goclaw\n", envPath)
fmt.Println("── Next Steps ──")
fmt.Println()
fmt.Println(" 1. Start the gateway:")
fmt.Printf(" source %s && ./goclaw\n", envPath)
fmt.Println()
fmt.Println(" 2. Open the dashboard to complete setup:")
fmt.Printf(" http://localhost:%s\n", port)
fmt.Println()
fmt.Println(" The setup wizard will guide you through:")
fmt.Println(" → Provider & API key configuration")
fmt.Println(" → Model selection & verification")
fmt.Println(" → Agent creation")
fmt.Println(" → Channel setup (optional)")
fmt.Println()
}
-323
View File
@@ -1,323 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// providerPriority defines the order in which providers are auto-detected
// from environment variables. First match wins.
var providerPriority = []string{
"openrouter", "anthropic", "openai", "groq", "deepseek",
"gemini", "mistral", "xai", "minimax", "cohere", "perplexity",
}
// canAutoOnboard returns true if any GOCLAW_*_API_KEY env var is set,
// indicating the user wants non-interactive configuration (e.g. Docker).
func canAutoOnboard() bool {
for _, name := range providerPriority {
pi, ok := providerMap[name]
if !ok || pi.envKey == "" {
continue
}
if os.Getenv(pi.envKey) != "" {
return true
}
}
return false
}
// runAutoOnboard performs non-interactive setup from environment variables.
// Returns true on success, false on fatal error.
func runAutoOnboard(cfgPath string) bool {
fmt.Println("Auto-onboard: environment variables detected, running non-interactive setup...")
cfg := config.Default()
cfg.ApplyEnvOverrides()
// 1. Resolve provider: respect GOCLAW_PROVIDER if set, otherwise auto-detect.
provider := cfg.Agents.Defaults.Provider // may be set by GOCLAW_PROVIDER via ApplyEnvOverrides
apiKey := ""
if provider != "" {
apiKey = resolveProviderAPIKey(cfg, provider)
}
if apiKey == "" {
// No explicit provider or no API key for it — auto-detect from available keys
provider, apiKey = detectProvider(cfg)
}
if provider == "" {
fmt.Println("Auto-onboard: no provider API key found in environment")
return false
}
cfg.Agents.Defaults.Provider = provider
// Use model hint if no model override set via GOCLAW_MODEL
if cfg.Agents.Defaults.Model == "" || cfg.Agents.Defaults.Model == config.Default().Agents.Defaults.Model {
if pi, ok := providerMap[provider]; ok && pi.modelHint != "" {
cfg.Agents.Defaults.Model = pi.modelHint
}
}
fmt.Printf(" Provider: %s (model: %s)\n", provider, cfg.Agents.Defaults.Model)
// 2. Auto-enable memory: detect embedding-capable API keys from env.
// Embedding providers: openai, openrouter, gemini (same order as resolveEmbeddingProvider).
embProvider := autoDetectEmbeddingProvider(cfg)
if embProvider != "" {
enabled := true
cfg.Agents.Defaults.Memory = &config.MemoryConfig{
Enabled: &enabled,
EmbeddingProvider: embProvider,
}
fmt.Printf(" Memory: enabled (embedding: %s)\n", embProvider)
} else {
fmt.Println(" Memory: enabled (FTS-only, no embedding API key)")
enabled := true
cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &enabled}
}
// 3. Gateway token
if cfg.Gateway.Token == "" {
cfg.Gateway.Token = onboardGenerateToken(16)
slog.Info("auto-onboard: generated gateway token")
}
// 3. Database: Postgres setup
if cfg.Database.PostgresDSN != "" {
fmt.Print(" Testing Postgres connection...")
// Retry loop: database container may still be starting
var pgErr error
for attempt := 1; attempt <= 5; attempt++ {
pgErr = testPostgresConnection(cfg.Database.PostgresDSN)
if pgErr == nil {
break
}
if attempt < 5 {
fmt.Printf(" retry %d/5...", attempt)
time.Sleep(2 * time.Second)
}
}
if pgErr != nil {
fmt.Println(" FAILED")
fmt.Printf(" Error: %v\n", pgErr)
return false
}
fmt.Println(" OK")
// Generate encryption key if not set
if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" {
encKey := onboardGenerateToken(32)
os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
slog.Info("auto-onboard: generated encryption key")
}
// Run migrations (idempotent)
fmt.Print(" Running migrations...")
m, err := newMigrator(cfg.Database.PostgresDSN)
if err != nil {
fmt.Printf(" error: %v\n", err)
fmt.Println(" Continuing without migration (run manually: goclaw migrate up)")
} else {
if err := m.Up(); err != nil && err.Error() != "no change" {
fmt.Printf(" error: %v\n", err)
fmt.Println(" Continuing without migration (run manually: goclaw migrate up)")
} else {
v, _, _ := m.Version()
fmt.Printf(" OK (version: %d)\n", v)
}
m.Close()
}
// Verify provider connectivity for all configured providers before seeding.
// Only the primary provider's auth failure blocks bootstrap.
fmt.Println(" Verifying provider connectivity...")
if fatalErrors := verifyAllProviders(cfg, provider); len(fatalErrors) > 0 {
slog.Error("auto-onboard: primary provider verification failed", "errors", fatalErrors)
fmt.Printf(" Provider verification FAILED: primary provider %q has invalid API key\n", provider)
return false
}
// Seed default data (non-fatal if already exists)
fmt.Print(" Seeding default agent/provider...")
if err := seedManagedData(cfg.Database.PostgresDSN, cfg); err != nil {
fmt.Printf(" skipped: %v\n", err)
} else {
fmt.Println(" OK")
}
}
// 4. Save config (clean, minimal — secrets stripped, unused sections omitted)
savedDSN := cfg.Database.PostgresDSN
if err := saveCleanConfig(cfgPath, cfg); err != nil {
fmt.Printf(" Warning: could not save config: %v\n", err)
} else {
fmt.Printf(" Config saved to %s\n", cfgPath)
}
// Restore DSN and re-apply env overrides so the runtime config has secrets
cfg.Database.PostgresDSN = savedDSN
cfg.ApplyEnvOverrides()
_ = apiKey // apiKey is already applied via ApplyEnvOverrides
fmt.Println("Auto-onboard complete.")
return true
}
// embeddingCapable lists providers that support text embeddings.
// Only these three have embedding provider implementations in resolveEmbeddingProvider.
var embeddingCapable = map[string]bool{
"openai": true,
"openrouter": true,
"gemini": true,
}
// autoDetectEmbeddingProvider picks an embedding provider from available API keys.
// Priority: primary provider (GOCLAW_PROVIDER) if embedding-capable, then openai → openrouter → gemini.
func autoDetectEmbeddingProvider(cfg *config.Config) string {
// Prioritize the primary provider if it supports embeddings.
primary := cfg.Agents.Defaults.Provider
if embeddingCapable[primary] && resolveProviderAPIKey(cfg, primary) != "" {
return primary
}
// Fallback: first available embedding-capable key.
if cfg.Providers.OpenAI.APIKey != "" {
return "openai"
}
if cfg.Providers.OpenRouter.APIKey != "" {
return "openrouter"
}
if cfg.Providers.Gemini.APIKey != "" {
return "gemini"
}
return ""
}
// detectProvider finds the first provider with an API key in the environment.
func detectProvider(cfg *config.Config) (string, string) {
for _, name := range providerPriority {
key := resolveProviderAPIKey(cfg, name)
if key != "" {
return name, key
}
}
return "", ""
}
// saveCleanConfig saves a minimal config.json without noise (empty providers,
// disabled channels, stripped secrets). Only includes sections relevant to
// the active configuration so the file serves as clean documentation.
// Channels are stored in the DB (channel_instances table),
// so they are omitted from config.json to avoid dual-connection.
func saveCleanConfig(cfgPath string, cfg *config.Config) error {
// Channels are stored in the DB (channel_instances table),
// so they are omitted from config.json to avoid dual-connection.
channels := make(map[string]interface{})
// Build tools section.
tools := map[string]interface{}{
"web": map[string]interface{}{
"duckduckgo": map[string]interface{}{
"enabled": cfg.Tools.Web.DuckDuckGo.Enabled,
"max_results": nonZero(cfg.Tools.Web.DuckDuckGo.MaxResults, 5),
},
},
"browser": map[string]interface{}{
"enabled": cfg.Tools.Browser.Enabled,
"headless": cfg.Tools.Browser.Headless,
"remote_url": cfg.Tools.Browser.RemoteURL,
},
"execApproval": map[string]interface{}{
"security": nonEmpty(cfg.Tools.ExecApproval.Security, "full"),
"ask": nonEmpty(cfg.Tools.ExecApproval.Ask, "off"),
},
}
// Build agents section.
agents := map[string]interface{}{
"defaults": map[string]interface{}{
"workspace": cfg.Agents.Defaults.Workspace,
"restrict_to_workspace": cfg.Agents.Defaults.RestrictToWorkspace,
"provider": cfg.Agents.Defaults.Provider,
"model": cfg.Agents.Defaults.Model,
"max_tokens": cfg.Agents.Defaults.MaxTokens,
"temperature": cfg.Agents.Defaults.Temperature,
"max_tool_iterations": cfg.Agents.Defaults.MaxToolIterations,
"context_window": cfg.Agents.Defaults.ContextWindow,
},
}
if cfg.Agents.Defaults.Subagents != nil {
agents["defaults"].(map[string]interface{})["subagents"] = cfg.Agents.Defaults.Subagents
}
if mc := cfg.Agents.Defaults.Memory; mc != nil {
mem := map[string]interface{}{
"enabled": mc.Enabled == nil || *mc.Enabled,
}
if mc.EmbeddingProvider != "" {
mem["embedding_provider"] = mc.EmbeddingProvider
}
if mc.EmbeddingModel != "" {
mem["embedding_model"] = mc.EmbeddingModel
}
agents["defaults"].(map[string]interface{})["memory"] = mem
}
// Build gateway section (no token — secret).
gateway := map[string]interface{}{
"host": cfg.Gateway.Host,
"port": cfg.Gateway.Port,
"max_message_chars": nonZero(cfg.Gateway.MaxMessageChars, 32000),
"rate_limit_rpm": nonZero(cfg.Gateway.RateLimitRPM, 20),
"inbound_debounce_ms": nonZero(cfg.Gateway.InboundDebounceMs, 1000),
}
// Build root config map.
root := map[string]interface{}{
"agents": agents,
"gateway": gateway,
"tools": tools,
}
if len(channels) > 0 {
root["channels"] = channels
}
data, err := json.MarshalIndent(root, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cfgPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(cfgPath, data, 0600)
}
// nonEmpty returns val if non-empty, otherwise fallback.
func nonEmpty(val, fallback string) string {
if val != "" {
return val
}
return fallback
}
// nonZero returns val if non-zero, otherwise fallback.
func nonZero(val, fallback int) int {
if val != 0 {
return val
}
return fallback
}
-18
View File
@@ -1,18 +0,0 @@
package cmd
import (
"fmt"
"os"
"os/exec"
)
// runClaudeAuthLogin runs `claude auth login` interactively,
// connecting stdin/stdout/stderr to the terminal for the browser OAuth flow.
func runClaudeAuthLogin(cliPath string) error {
fmt.Println(" Opening browser for Claude authentication...")
cmd := exec.Command(cliPath, "auth", "login")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
-44
View File
@@ -1,44 +0,0 @@
package cmd
import "fmt"
// promptFeishuConfig runs the Feishu/Lark configuration prompts sequentially.
func promptFeishuConfig(appID, appSecret, domain, connMode *string) error {
fmt.Println("\n ── Feishu / Lark Configuration ──")
val, err := promptString("Feishu App ID", "From the Feishu/Lark Open Platform console", *appID)
if err != nil {
return err
}
*appID = val
val, err = promptPassword("Feishu App Secret", "Keep this secret safe")
if err != nil {
return err
}
if val != "" {
*appSecret = val
}
// API Domain
d, err := promptSelect("API Domain", []SelectOption[string]{
{"Lark Global (open.larksuite.com)", "lark"},
{"Feishu China (open.feishu.cn)", "feishu"},
}, 0)
if err != nil {
return err
}
*domain = d
// Connection Mode
cm, err := promptSelect("Connection Mode", []SelectOption[string]{
{"WebSocket (recommended, no public URL needed)", "websocket"},
{"Webhook (requires public URL)", "webhook"},
}, 0)
if err != nil {
return err
}
*connMode = cm
return nil
}
+6 -121
View File
@@ -6,138 +6,23 @@ import (
"fmt"
"os"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// resolveExistingAPIKey tries to find an existing API key from config or env vars.
func resolveExistingAPIKey(cfg *config.Config, provider string) string {
key := resolveProviderAPIKey(cfg, provider)
if key != "" {
return key
}
if pi, ok := providerMap[provider]; ok && pi.envKey != "" {
if envKey := os.Getenv(pi.envKey); envKey != "" {
return envKey
}
}
return ""
}
// applyProviderAPIKey stores the API key in the correct config field.
func applyProviderAPIKey(cfg *config.Config, provider, key string) {
switch provider {
case "openrouter":
cfg.Providers.OpenRouter.APIKey = key
case "anthropic":
cfg.Providers.Anthropic.APIKey = key
case "openai":
cfg.Providers.OpenAI.APIKey = key
case "groq":
cfg.Providers.Groq.APIKey = key
case "deepseek":
cfg.Providers.DeepSeek.APIKey = key
case "gemini":
cfg.Providers.Gemini.APIKey = key
case "mistral":
cfg.Providers.Mistral.APIKey = key
case "xai":
cfg.Providers.XAI.APIKey = key
case "minimax":
cfg.Providers.MiniMax.APIKey = key
case "cohere":
cfg.Providers.Cohere.APIKey = key
case "perplexity":
cfg.Providers.Perplexity.APIKey = key
}
}
func onboardGenerateToken(bytes int) string {
b := make([]byte, bytes)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func onboardWriteEnvFile(path string, cfg *config.Config, primaryKey, primaryEnvKey string, traceVerbose bool) {
// onboardWriteEnvFile writes the minimal .env.local with only the 3 required secrets.
func onboardWriteEnvFile(path, postgresDSN, gatewayToken, encryptionKey string) {
var lines []string
lines = append(lines, "# GoClaw — auto-generated by onboard wizard")
lines = append(lines, "# GoClaw — auto-generated by onboard")
lines = append(lines, "# Keep this file secret! Add to .gitignore.")
lines = append(lines, "")
if primaryEnvKey != "" && primaryKey != "" {
lines = append(lines, fmt.Sprintf("export %s=%s", primaryEnvKey, primaryKey))
}
// Claude CLI config (no API key, just path/model)
if cfg.Providers.ClaudeCLI.CLIPath != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_PATH=%s", cfg.Providers.ClaudeCLI.CLIPath))
if cfg.Providers.ClaudeCLI.Model != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_MODEL=%s", cfg.Providers.ClaudeCLI.Model))
}
}
// Add other configured provider keys (skip if same as primary)
addIfSet := func(envKey, value string) {
if value != "" && (primaryEnvKey == "" || envKey != primaryEnvKey) {
lines = append(lines, fmt.Sprintf("export %s=%s", envKey, value))
}
}
addIfSet("GOCLAW_ANTHROPIC_API_KEY", cfg.Providers.Anthropic.APIKey)
addIfSet("GOCLAW_OPENAI_API_KEY", cfg.Providers.OpenAI.APIKey)
addIfSet("GOCLAW_OPENROUTER_API_KEY", cfg.Providers.OpenRouter.APIKey)
addIfSet("GOCLAW_GROQ_API_KEY", cfg.Providers.Groq.APIKey)
addIfSet("GOCLAW_DEEPSEEK_API_KEY", cfg.Providers.DeepSeek.APIKey)
addIfSet("GOCLAW_GEMINI_API_KEY", cfg.Providers.Gemini.APIKey)
addIfSet("GOCLAW_MISTRAL_API_KEY", cfg.Providers.Mistral.APIKey)
addIfSet("GOCLAW_XAI_API_KEY", cfg.Providers.XAI.APIKey)
addIfSet("GOCLAW_MINIMAX_API_KEY", cfg.Providers.MiniMax.APIKey)
addIfSet("GOCLAW_COHERE_API_KEY", cfg.Providers.Cohere.APIKey)
addIfSet("GOCLAW_PERPLEXITY_API_KEY", cfg.Providers.Perplexity.APIKey)
if cfg.Gateway.Token != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", cfg.Gateway.Token))
}
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_TELEGRAM_TOKEN=%s", cfg.Channels.Telegram.Token))
}
if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_ZALO_TOKEN=%s", cfg.Channels.Zalo.Token))
}
if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppSecret != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_ID=%s", cfg.Channels.Feishu.AppID))
lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_SECRET=%s", cfg.Channels.Feishu.AppSecret))
}
// Database
if cfg.Database.PostgresDSN != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", cfg.Database.PostgresDSN))
}
// Encryption key for API keys in DB
if encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY"); encKey != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encKey))
}
// TTS secrets
if cfg.Tts.OpenAI.APIKey != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_OPENAI_API_KEY=%s", cfg.Tts.OpenAI.APIKey))
}
if cfg.Tts.ElevenLabs.APIKey != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_ELEVENLABS_API_KEY=%s", cfg.Tts.ElevenLabs.APIKey))
}
if cfg.Tts.MiniMax.APIKey != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_MINIMAX_API_KEY=%s", cfg.Tts.MiniMax.APIKey))
}
if cfg.Tts.MiniMax.GroupID != "" {
lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_MINIMAX_GROUP_ID=%s", cfg.Tts.MiniMax.GroupID))
}
// Verbose tracing
if traceVerbose {
lines = append(lines, "export GOCLAW_TRACE_VERBOSE=1")
}
lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", postgresDSN))
lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", gatewayToken))
lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encryptionKey))
lines = append(lines, "")
content := strings.Join(lines, "\n")
+20 -273
View File
@@ -2,16 +2,11 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
@@ -29,259 +24,6 @@ func testPostgresConnection(dsn string) error {
return db.PingContext(ctx)
}
// seedManagedData inserts providers, default model, and default agent into Postgres
// so the gateway has something to work with on first start.
// All providers with API keys are seeded (not just the default one).
// Idempotent: duplicate entries are skipped on re-run.
func seedManagedData(dsn string, cfg *config.Config) error {
storeCfg := store.StoreConfig{
PostgresDSN: dsn,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
}
stores, err := pg.NewPGStores(storeCfg)
if err != nil {
return fmt.Errorf("open PG stores: %w", err)
}
ctx := context.Background()
// Resolve owner: use first GOCLAW_OWNER_IDS entry if set, otherwise "system".
ownerID := "system"
if len(cfg.Gateway.OwnerIDs) > 0 && cfg.Gateway.OwnerIDs[0] != "" {
ownerID = cfg.Gateway.OwnerIDs[0]
}
defaultProvider := cfg.Agents.Defaults.Provider
if defaultProvider == "" {
defaultProvider = "openrouter"
}
// 1. Seed all providers that have API keys.
// Errors are non-fatal per provider (e.g. unique violation on re-run).
var seededCount int
for _, name := range providerPriority {
apiKey := resolveProviderAPIKey(cfg, name)
if apiKey == "" {
continue
}
providerType := resolveProviderType(name)
p := &store.LLMProviderData{
Name: name,
DisplayName: name,
ProviderType: providerType,
APIBase: resolveProviderAPIBase(name),
APIKey: apiKey,
Enabled: true,
}
if err := stores.Providers.CreateProvider(ctx, p); err != nil {
slog.Debug("seed provider skipped (may already exist)", "name", name, "error", err)
continue
}
seededCount++
slog.Info("seeded provider", "name", name)
}
if seededCount > 0 {
fmt.Printf(" Seeded %d provider(s)\n", seededCount)
}
// 2. Find the default provider's ID from DB (handles both fresh seed and re-run).
allProviders, err := stores.Providers.ListProviders(ctx)
if err != nil {
return fmt.Errorf("list providers: %w", err)
}
var defaultProviderID uuid.UUID
for _, p := range allProviders {
if p.Name == defaultProvider {
defaultProviderID = p.ID
break
}
}
if defaultProviderID == uuid.Nil {
return fmt.Errorf("default provider %q not found in DB (no API key?)", defaultProvider)
}
// 3. Resolve default model string (used for agent seed below).
modelID := cfg.Agents.Defaults.Model
if modelID == "" {
modelID = "anthropic/claude-sonnet-4-5-20250929"
}
// 4. Seed default agent (skip if already exists)
workspace := config.ExpandHome(cfg.Agents.Defaults.Workspace)
agent := &store.AgentData{
AgentKey: "default",
DisplayName: "Default Agent",
OwnerID: ownerID,
AgentType: store.AgentTypeOpen,
Provider: defaultProvider,
Model: modelID,
Workspace: workspace,
RestrictToWorkspace: true,
IsDefault: true,
Status: store.AgentStatusActive,
SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`),
}
if err := stores.Agents.Create(ctx, agent); err != nil {
slog.Debug("seed agent skipped (may already exist)", "error", err)
return nil
}
// 5. Seed context files into agent_context_files (only for predefined agents;
// open agents get per-user files via SeedUserFiles on first chat)
if _, err := bootstrap.SeedToStore(ctx, stores.Agents, agent.ID, agent.AgentType); err != nil {
return fmt.Errorf("seed context files: %w", err)
}
// 6. Seed channel instances from env vars (if set)
seedChannelInstances(ctx, stores, cfg, agent.ID, ownerID)
// 7. Seed config_secrets from env vars
seedConfigSecrets(ctx, stores, cfg)
// 8. Seed placeholder providers for UI discoverability
seedDefaultPlaceholders(ctx, stores)
return nil
}
// seedChannelInstances creates channel_instances rows from env-var-provided credentials.
// Idempotent: skips if an instance with the same name already exists.
func seedChannelInstances(ctx context.Context, stores *store.Stores, cfg *config.Config, defaultAgentID uuid.UUID, ownerID string) {
if stores.ChannelInstances == nil {
return
}
type seed struct {
name string // channel name in the system
channelType string
display string
creds map[string]string
config map[string]interface{}
}
var seeds []seed
// Telegram: use legacy name "telegram" for backward compat with existing session keys.
if cfg.Channels.Telegram.Token != "" {
tgConfig := map[string]interface{}{
"dm_policy": nonEmpty(cfg.Channels.Telegram.DMPolicy, "pairing"),
"group_policy": nonEmpty(cfg.Channels.Telegram.GroupPolicy, "pairing"),
"reaction_level": nonEmpty(cfg.Channels.Telegram.ReactionLevel, "full"),
"history_limit": nonZero(cfg.Channels.Telegram.HistoryLimit, 50),
}
if cfg.Channels.Telegram.RequireMention != nil {
tgConfig["require_mention"] = *cfg.Channels.Telegram.RequireMention
}
if cfg.Channels.Telegram.MediaMaxBytes > 0 {
tgConfig["media_max_bytes"] = cfg.Channels.Telegram.MediaMaxBytes
}
if cfg.Channels.Telegram.LinkPreview != nil {
tgConfig["link_preview"] = *cfg.Channels.Telegram.LinkPreview
}
if len(cfg.Channels.Telegram.AllowFrom) > 0 {
tgConfig["allow_from"] = cfg.Channels.Telegram.AllowFrom
}
seeds = append(seeds, seed{
name: "telegram", channelType: "telegram", display: "Telegram Bot",
creds: map[string]string{"token": cfg.Channels.Telegram.Token, "proxy": cfg.Channels.Telegram.Proxy},
config: tgConfig,
})
}
// Other channels: use {type}/default format (no legacy data to preserve).
if cfg.Channels.Discord.Token != "" {
seeds = append(seeds, seed{
name: "discord/default", channelType: "discord", display: "Discord Bot",
creds: map[string]string{"token": cfg.Channels.Discord.Token},
config: map[string]interface{}{"dm_policy": cfg.Channels.Discord.DMPolicy, "group_policy": cfg.Channels.Discord.GroupPolicy},
})
}
if cfg.Channels.Feishu.AppID != "" && cfg.Channels.Feishu.AppSecret != "" {
seeds = append(seeds, seed{
name: "feishu/default", channelType: "feishu", display: "Feishu/Lark Bot",
creds: map[string]string{
"app_id": cfg.Channels.Feishu.AppID, "app_secret": cfg.Channels.Feishu.AppSecret,
"encrypt_key": cfg.Channels.Feishu.EncryptKey, "verification_token": cfg.Channels.Feishu.VerificationToken,
},
config: map[string]interface{}{"dm_policy": cfg.Channels.Feishu.DMPolicy, "domain": cfg.Channels.Feishu.Domain},
})
}
if cfg.Channels.Zalo.Token != "" {
seeds = append(seeds, seed{
name: "zalo_oa/default", channelType: "zalo_oa", display: "Zalo OA",
creds: map[string]string{"token": cfg.Channels.Zalo.Token, "webhook_secret": cfg.Channels.Zalo.WebhookSecret},
config: map[string]interface{}{"dm_policy": cfg.Channels.Zalo.DMPolicy},
})
}
if cfg.Channels.WhatsApp.BridgeURL != "" {
seeds = append(seeds, seed{
name: "whatsapp/default", channelType: "whatsapp", display: "WhatsApp",
creds: map[string]string{"bridge_url": cfg.Channels.WhatsApp.BridgeURL},
config: map[string]interface{}{"dm_policy": cfg.Channels.WhatsApp.DMPolicy, "group_policy": cfg.Channels.WhatsApp.GroupPolicy},
})
}
seeded := 0
for _, s := range seeds {
credsJSON, _ := json.Marshal(s.creds)
cfgJSON, _ := json.Marshal(s.config)
inst := &store.ChannelInstanceData{
Name: s.name,
DisplayName: s.display,
ChannelType: s.channelType,
AgentID: defaultAgentID,
Credentials: credsJSON,
Config: cfgJSON,
Enabled: true,
CreatedBy: ownerID,
}
if err := stores.ChannelInstances.Create(ctx, inst); err != nil {
slog.Debug("seed channel instance skipped (may already exist)", "name", s.name, "error", err)
continue
}
seeded++
slog.Info("seeded channel instance", "name", s.name, "type", s.channelType)
}
if seeded > 0 {
fmt.Printf(" Seeded %d channel instance(s)\n", seeded)
}
}
// seedConfigSecrets saves non-LLM/non-channel secrets to the config_secrets table.
// These are secrets that don't belong in llm_providers or channel_instances tables.
func seedConfigSecrets(ctx context.Context, stores *store.Stores, cfg *config.Config) {
if stores.ConfigSecrets == nil {
return
}
secrets := cfg.ExtractDBSecrets()
seeded := 0
for key, value := range secrets {
if err := stores.ConfigSecrets.Set(ctx, key, value); err != nil {
slog.Debug("seed config secret failed", "key", key, "error", err)
continue
}
seeded++
}
if seeded > 0 {
slog.Info("seeded config secrets", "count", seeded)
}
}
// defaultPlaceholderProviders defines disabled placeholder providers seeded for
// UI discoverability. Users can later enable and configure them via the dashboard.
var defaultPlaceholderProviders = []store.LLMProviderData{
@@ -291,21 +33,28 @@ var defaultPlaceholderProviders = []store.LLMProviderData{
{Name: "alicloud-sub", DisplayName: "AliCloud Sub", ProviderType: store.ProviderBailian, APIBase: "https://coding-intl.dashscope.aliyuncs.com/v1", Enabled: false},
}
// seedDefaultPlaceholders inserts disabled placeholder providers so they
// appear in the UI for easy configuration. Idempotent: UNIQUE(name) constraint
// skips duplicates, and providers whose api_base already exists are skipped
// to avoid overwriting user-configured entries.
func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) {
if stores.Providers == nil {
return
// seedOnboardPlaceholders opens a PG store and seeds disabled placeholder providers
// so they appear in the UI for easy configuration.
func seedOnboardPlaceholders(dsn string) error {
storeCfg := store.StoreConfig{
PostgresDSN: dsn,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
}
stores, err := pg.NewPGStores(storeCfg)
if err != nil {
return fmt.Errorf("open PG stores: %w", err)
}
// Build a set of existing api_base values to avoid seeding a placeholder
// when a user-configured provider already uses that base URL.
if stores.Providers == nil {
return nil
}
ctx := context.Background()
// Build a set of existing api_base values to avoid overwriting user-configured entries.
existing, err := stores.Providers.ListProviders(ctx)
if err != nil {
slog.Debug("seedDefaultPlaceholders: list providers failed", "error", err)
return
return fmt.Errorf("list providers: %w", err)
}
existingBases := make(map[string]bool, len(existing))
for _, p := range existing {
@@ -316,11 +65,9 @@ func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) {
seeded := 0
for _, ph := range defaultPlaceholderProviders {
// Skip if a provider with this api_base already exists
if ph.APIBase != "" && existingBases[ph.APIBase] {
continue
}
p := ph // copy
if err := stores.Providers.CreateProvider(ctx, &p); err != nil {
slog.Debug("seed placeholder skipped (may already exist)", "name", ph.Name, "error", err)
@@ -330,7 +77,7 @@ func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) {
}
if seeded > 0 {
fmt.Printf(" Seeded %d placeholder provider(s)\n", seeded)
slog.Info("seeded placeholder providers", "count", seeded)
}
return nil
}
-123
View File
@@ -1,123 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
// topOpenRouterProviders is a curated whitelist of the best model providers on OpenRouter.
var topOpenRouterProviders = map[string]bool{
"anthropic": true,
"openai": true,
"google": true,
"mistralai": true,
"deepseek": true,
"meta-llama": true,
"qwen": true,
"x-ai": true,
"z-ai": true,
"nvidia": true,
"moonshotai": true,
"minimax": true,
"allenai": true,
"cohere": true,
"perplexity": true,
"amazon": true,
"microsoft": true,
"ai21": true,
"nousresearch": true,
"baidu": true,
}
type openRouterModel struct {
ID string `json:"id"`
Name string `json:"name"`
ContextLength int `json:"context_length"`
}
func fetchOpenRouterModels() ([]openRouterModel, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://openrouter.ai/api/v1/models")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result struct {
Data []openRouterModel `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
sort.Slice(result.Data, func(i, j int) bool {
return result.Data[i].ID < result.Data[j].ID
})
return result.Data, nil
}
func filterTopProviderModels(models []openRouterModel) []openRouterModel {
var filtered []openRouterModel
for _, m := range models {
parts := strings.SplitN(m.ID, "/", 2)
if len(parts) == 2 && topOpenRouterProviders[parts[0]] {
filtered = append(filtered, m)
}
}
return filtered
}
func formatCtx(ctxLen int) string {
if ctxLen >= 1_000_000 {
return fmt.Sprintf("%.0fM ctx", float64(ctxLen)/1_000_000)
}
if ctxLen >= 1000 {
return fmt.Sprintf("%dK ctx", ctxLen/1000)
}
return fmt.Sprintf("%d ctx", ctxLen)
}
// fallbackModels used when API is unreachable.
var fallbackModels = []openRouterModel{
{ID: "anthropic/claude-sonnet-4-5-20250929", Name: "Claude Sonnet 4.5", ContextLength: 200000},
{ID: "openai/gpt-4o", Name: "GPT-4o", ContextLength: 128000},
{ID: "google/gemini-2.0-flash-001", Name: "Gemini 2.0 Flash", ContextLength: 1048576},
{ID: "deepseek/deepseek-chat-v3-0324", Name: "DeepSeek V3", ContextLength: 131072},
}
// buildOpenRouterModelOptions builds select options from OpenRouter models.
// Pre-fetches from API; uses fallback on failure.
func buildOpenRouterModelOptions() []SelectOption[string] {
models, err := fetchOpenRouterModels()
if err != nil || len(models) == 0 {
models = fallbackModels
} else {
models = filterTopProviderModels(models)
if len(models) == 0 {
models = fallbackModels
}
}
options := make([]SelectOption[string], 0, len(models)+1)
for _, m := range models {
label := fmt.Sprintf("%-50s (%s)", m.ID, formatCtx(m.ContextLength))
options = append(options, SelectOption[string]{Label: label, Value: m.ID})
}
options = append(options, SelectOption[string]{Label: "Enter custom model ID...", Value: "__custom__"})
return options
}
-98
View File
@@ -1,98 +0,0 @@
package cmd
import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// resolveProviderAPIKey extracts the API key for a provider from the config.
func resolveProviderAPIKey(cfg *config.Config, providerName string) string {
switch providerName {
case "openrouter":
return cfg.Providers.OpenRouter.APIKey
case "anthropic":
return cfg.Providers.Anthropic.APIKey
case "openai":
return cfg.Providers.OpenAI.APIKey
case "groq":
return cfg.Providers.Groq.APIKey
case "deepseek":
return cfg.Providers.DeepSeek.APIKey
case "gemini":
return cfg.Providers.Gemini.APIKey
case "mistral":
return cfg.Providers.Mistral.APIKey
case "xai":
return cfg.Providers.XAI.APIKey
case "minimax":
return cfg.Providers.MiniMax.APIKey
case "cohere":
return cfg.Providers.Cohere.APIKey
case "perplexity":
return cfg.Providers.Perplexity.APIKey
default:
return ""
}
}
// resolveProviderType maps a provider name to its store.Provider* type constant.
func resolveProviderType(name string) string {
switch name {
case "anthropic":
return store.ProviderAnthropicNative
case "gemini":
return store.ProviderGeminiNative
case "minimax":
return store.ProviderMiniMax
case "openrouter":
return store.ProviderOpenRouter
case "groq":
return store.ProviderGroq
case "deepseek":
return store.ProviderDeepSeek
case "mistral":
return store.ProviderMistral
case "xai":
return store.ProviderXAI
case "cohere":
return store.ProviderCohere
case "perplexity":
return store.ProviderPerplexity
case "yescale":
return store.ProviderYesScale
default:
return store.ProviderOpenAICompat
}
}
// resolveProviderAPIBase returns the default API base URL for known providers.
func resolveProviderAPIBase(providerName string) string {
switch providerName {
case "openrouter":
return "https://openrouter.ai/api/v1"
case "anthropic":
return "https://api.anthropic.com/v1"
case "openai":
return "https://api.openai.com/v1"
case "groq":
return "https://api.groq.com/openai/v1"
case "deepseek":
return "https://api.deepseek.com/v1"
case "gemini":
return "https://generativelanguage.googleapis.com/v1beta/openai"
case "mistral":
return "https://api.mistral.ai/v1"
case "xai":
return "https://api.x.ai/v1"
case "minimax":
return "https://api.minimax.io/v1"
case "cohere":
return "https://api.cohere.com/v2"
case "perplexity":
return "https://api.perplexity.ai"
case "yescale":
return "https://api.yescale.one/v1"
default:
return ""
}
}
-57
View File
@@ -1,57 +0,0 @@
package cmd
import "fmt"
// promptTTSConfig runs the TTS configuration prompts sequentially.
// Returns early on any error (e.g. user pressed Ctrl+C).
func promptTTSConfig(ttsProvider, ttsAPIKey, ttsGroupID, ttsAutoMode *string) error {
// TTS provider
provider, err := promptSelect("TTS Provider (Text-to-Speech for agent replies)", []SelectOption[string]{
{"None (disabled)", "none"},
{"OpenAI (gpt-4o-mini-tts, alloy voice)", "openai"},
{"ElevenLabs (high quality, multilingual)", "elevenlabs"},
{"MiniMax (speech-02-hd, 300+ voices)", "minimax"},
{"Edge (free Microsoft Edge TTS, no API key)", "edge"},
}, 0)
if err != nil {
return err
}
*ttsProvider = provider
if *ttsProvider == "none" {
return nil
}
// TTS auto-apply mode
autoMode, err := promptSelect("TTS auto-apply mode", []SelectOption[string]{
{"Off (agent can use tts tool manually)", "off"},
{"Always (all replies get audio)", "always"},
{"Inbound (only when user sends voice/audio)", "inbound"},
{"Tagged (only when reply has [[tts]] tag)", "tagged"},
}, 0)
if err != nil {
return err
}
*ttsAutoMode = autoMode
// API key (not needed for edge)
if *ttsProvider != "edge" {
key, err := promptPassword("TTS API Key", "Leave empty to reuse your chat provider's API key (if same provider)")
if err != nil {
return err
}
*ttsAPIKey = key
}
// MiniMax Group ID
if *ttsProvider == "minimax" {
groupID, err := promptString("MiniMax Group ID", "Only needed for MiniMax TTS", *ttsGroupID)
if err != nil {
return err
}
*ttsGroupID = groupID
}
fmt.Println()
return nil
}
-173
View File
@@ -1,173 +0,0 @@
package cmd
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// providerVerifyError holds the result of a provider connectivity probe.
type providerVerifyError struct {
fatal bool // true = bad credentials, block startup
message string // human-readable description
}
func (e *providerVerifyError) Error() string { return e.message }
// newProviderForVerify instantiates a temporary provider for key verification.
// Mirrors the logic in registerProviders (gateway_providers.go) so auth headers,
// base URL overrides, and custom chat paths are handled correctly.
func newProviderForVerify(cfg *config.Config, name string) providers.Provider {
apiKey := resolveProviderAPIKey(cfg, name)
apiBase := resolveProviderAPIBase(name)
switch name {
case "anthropic":
if cfg.Providers.Anthropic.APIBase != "" {
apiBase = cfg.Providers.Anthropic.APIBase
}
return providers.NewAnthropicProvider(apiKey, providers.WithAnthropicBaseURL(apiBase))
case "dashscope":
if cfg.Providers.DashScope.APIBase != "" {
apiBase = cfg.Providers.DashScope.APIBase
}
return providers.NewDashScopeProvider(apiKey, apiBase, "")
case "minimax":
return providers.NewOpenAIProvider(name, apiKey, apiBase, "").WithChatPath("/text/chatcompletion_v2")
case "openai":
if cfg.Providers.OpenAI.APIBase != "" {
apiBase = cfg.Providers.OpenAI.APIBase
}
return providers.NewOpenAIProvider(name, apiKey, apiBase, "")
case "bailian":
if cfg.Providers.Bailian.APIBase != "" {
apiBase = cfg.Providers.Bailian.APIBase
}
return providers.NewOpenAIProvider(name, apiKey, apiBase, "")
default:
return providers.NewOpenAIProvider(name, apiKey, apiBase, "")
}
}
// verifyProviderConnectivity checks whether a provider's API key is valid by
// sending a minimal Chat request through the provider layer. This reuses the
// same auth headers, base URLs, and HTTP client as the real provider calls.
//
// Strategy: provider.Chat() with message "hi", max_tokens=1.
// - 401/403 HTTPError → invalid API key (fatal)
// - Any other error → non-fatal warning (transient/config issue)
// - Success → key is valid
func verifyProviderConnectivity(cfg *config.Config, providerName string) *providerVerifyError {
apiKey := resolveProviderAPIKey(cfg, providerName)
if apiKey == "" {
return nil
}
apiBase := resolveProviderAPIBase(providerName)
if apiBase == "" {
return nil // custom/unknown provider — skip
}
prov := newProviderForVerify(cfg, providerName)
model := ""
if pi, ok := providerMap[providerName]; ok {
model = pi.modelHint
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := prov.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{{Role: "user", Content: "hi"}},
Model: model,
Options: map[string]interface{}{"max_tokens": 1},
})
if err != nil {
var httpErr *providers.HTTPError
if errors.As(err, &httpErr) && (httpErr.Status == 401 || httpErr.Status == 403) {
return &providerVerifyError{
fatal: true,
message: fmt.Sprintf("%s returned %d — invalid API key", providerName, httpErr.Status),
}
}
// Non-auth errors: transient network issue, bad model, rate limit, etc.
return &providerVerifyError{
fatal: false,
message: fmt.Sprintf("%s: %s", providerName, friendlyProviderError(err)),
}
}
return nil
}
// verifyAllProviders checks connectivity for every provider that has an API key.
// Only the primary provider's auth failure is fatal (blocks bootstrap).
// Secondary provider failures are logged as warnings.
func verifyAllProviders(cfg *config.Config, primaryProvider string) []string {
var fatalErrors []string
for _, name := range providerPriority {
apiKey := resolveProviderAPIKey(cfg, name)
if apiKey == "" {
continue
}
verr := verifyProviderConnectivity(cfg, name)
if verr == nil {
slog.Info("provider connectivity verified", "provider", name)
fmt.Printf(" %s: OK\n", name)
continue
}
if verr.fatal && name == primaryProvider {
slog.Error("primary provider key invalid", "provider", name, "error", verr.message)
fmt.Printf(" %s: FAILED — %s\n", name, verr.message)
fatalErrors = append(fatalErrors, fmt.Sprintf("%s: %s", name, verr.message))
} else if verr.fatal {
slog.Warn("secondary provider key invalid (continuing)", "provider", name, "error", verr.message)
fmt.Printf(" %s: WARNING — %s (non-primary, skipping)\n", name, verr.message)
} else {
slog.Warn("provider connectivity warning", "provider", name, "warning", verr.message)
fmt.Printf(" %s: WARNING — %s\n", name, verr.message)
}
}
return fatalErrors
}
// friendlyProviderError extracts a human-readable message from provider errors.
func friendlyProviderError(err error) string {
msg := err.Error()
// Try to extract "message" field from embedded JSON error blobs.
if idx := strings.Index(msg, `"message"`); idx >= 0 {
rest := msg[idx:]
if start := strings.Index(rest, `:`); start >= 0 {
rest = strings.TrimLeft(rest[start+1:], " ")
if len(rest) > 0 && rest[0] == '"' {
rest = rest[1:]
if end := strings.Index(rest, `"`); end >= 0 && rest[:end] != "" {
return rest[:end]
}
}
}
}
// Strip "HTTP NNN: provider: " prefix.
if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx < len(msg)-2 {
suffix := msg[idx+2:]
if strings.HasPrefix(suffix, "{") {
return "request rejected by provider"
}
return suffix
}
return msg
}
-30
View File
@@ -1,30 +0,0 @@
package cmd
import "fmt"
// promptZaloConfig runs the Zalo OA Bot configuration prompts sequentially.
func promptZaloConfig(zaloToken, zaloDMPolicy *string) error {
fmt.Println("\n ── Zalo OA Configuration ──")
val, err := promptPassword("Zalo Bot API Token", "From the Zalo OA developer dashboard")
if err != nil {
return err
}
if val != "" {
*zaloToken = val
}
// DM Policy
policy, err := promptSelect("Zalo DM Policy — How to handle direct messages", []SelectOption[string]{
{"Pairing (require approval code)", "pairing"},
{"Open (anyone can chat)", "open"},
{"Allowlist (only allow_from IDs)", "allowlist"},
{"Disabled (reject all DMs)", "disabled"},
}, 0)
if err != nil {
return err
}
*zaloDMPolicy = policy
return nil
}