package main import ( "encoding/hex" "fmt" "io/fs" "log" "net/http" "os" "path/filepath" "github.com/mish/dealspace/api" "github.com/mish/dealspace/lib" ) func main() { cfg, err := loadConfig() if err != nil { log.Fatalf("config: %v", err) } // Resolve migration path relative to binary or working directory migrationPath := findMigration() db, err := lib.OpenDB(cfg.DBPath, migrationPath) if err != nil { log.Fatalf("database: %v", err) } defer db.Close() store, err := lib.NewLocalStore(cfg.StorePath) if err != nil { log.Fatalf("object store: %v", err) } // Always seed super admin accounts on startup seedSuperAdmins(db) // Seed OAuth client for Claude api.SeedOAuthClient(db) // Seed demo data if SEED_DEMO=true if os.Getenv("SEED_DEMO") == "true" { seedDemoData(db, cfg) } // Website FS: serve from disk (dev: website/, prod: /opt/dealspace/website/) var websiteFS fs.FS for _, dir := range []string{"website", "/opt/dealspace/website"} { if info, err := os.Stat(dir); err == nil && info.IsDir() { websiteFS = os.DirFS(dir) break } } // MCP server for Claude integration mcpServer := api.NewMCPServer(db, cfg) router := api.NewRouter(db, cfg, store, websiteFS, nil, mcpServer) addr := ":" + cfg.Port log.Printf("dealspace starting on %s (env=%s)", addr, cfg.Env) if err := http.ListenAndServe(addr, router); err != nil { log.Fatalf("server: %v", err) } } func loadConfig() (*lib.Config, error) { // Load .env if it exists loadDotEnv() masterKeyHex := os.Getenv("MASTER_KEY") if masterKeyHex == "" { return nil, fmt.Errorf("MASTER_KEY environment variable required") } masterKey, err := hex.DecodeString(masterKeyHex) if err != nil { return nil, fmt.Errorf("MASTER_KEY must be hex: %w", err) } if len(masterKey) != 32 { return nil, fmt.Errorf("MASTER_KEY must be 32 bytes (64 hex chars)") } dbPath := os.Getenv("DB_PATH") if dbPath == "" { dbPath = "dealspace.db" } storePath := os.Getenv("STORE_PATH") if storePath == "" { storePath = "store" } port := os.Getenv("PORT") if port == "" { port = "8080" } env := os.Getenv("ENV") if env == "" { env = "development" } // JWT secret derived from master key jwtSecret, err := lib.DeriveProjectKey(masterKey, "jwt-signing") if err != nil { return nil, fmt.Errorf("derive jwt secret: %w", err) } // Backdoor OTP code for dev/testing backdoorCode := os.Getenv("BACKDOOR_CODE") cfg := &lib.Config{ MasterKey: masterKey, DBPath: dbPath, StorePath: storePath, Port: port, Env: env, JWTSecret: jwtSecret, BackdoorCode: backdoorCode, } // Initialize mailer cfg.Mailer = lib.NewMailer(cfg) return cfg, nil } func loadDotEnv() { data, err := os.ReadFile(".env") if err != nil { return } for _, line := range splitLines(string(data)) { if line == "" || line[0] == '#' { continue } for i := 0; i < len(line); i++ { if line[i] == '=' { key := line[:i] val := line[i+1:] if os.Getenv(key) == "" { os.Setenv(key, val) } break } } } } func splitLines(s string) []string { var lines []string start := 0 for i := 0; i < len(s); i++ { if s[i] == '\n' { line := s[start:i] if len(line) > 0 && line[len(line)-1] == '\r' { line = line[:len(line)-1] } lines = append(lines, line) start = i + 1 } } if start < len(s) { lines = append(lines, s[start:]) } return lines } func findMigration() string { candidates := []string{ "migrations", filepath.Join(filepath.Dir(os.Args[0]), "migrations"), "/opt/dealspace/migrations", } for _, p := range candidates { if info, err := os.Stat(p); err == nil && info.IsDir() { return p } } return "" // no migration found, skip }