package cli import ( "bytes" "context" "fmt" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/BurntSushi/toml" "github.com/thepeterstone/claudomator/internal/api" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/version" "github.com/spf13/cobra" ) func newServeCmd() *cobra.Command { var addr string var workspaceRoot string cmd := &cobra.Command{ Use: "serve", Short: "Start the Claudomator API server", RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("workspace-root") { cfg.WorkspaceRoot = workspaceRoot } return serve(addr) }, } cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address") cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces") return cmd } func serve(addr string) error { if err := cfg.EnsureDirs(); err != nil { return fmt.Errorf("creating dirs: %w", err) } // Auto-generate VAPID keys if not configured. if cfg.VAPIDPublicKey == "" || cfg.VAPIDPrivateKey == "" { pub, priv, err := notify.GenerateVAPIDKeys() if err != nil { return fmt.Errorf("generating VAPID keys: %w", err) } cfg.VAPIDPublicKey = pub cfg.VAPIDPrivateKey = priv // Write new keys back to config file. if cfgFile != "" { if err := saveVAPIDToConfig(cfgFile, pub, priv); err != nil { // Non-fatal: log but continue. fmt.Fprintf(os.Stderr, "warning: failed to persist VAPID keys to %s: %v\n", cfgFile, err) } } } store, err := storage.Open(cfg.DBPath) if err != nil { return fmt.Errorf("opening db: %w", err) } defer store.Close() logger := newLogger(verbose) apiURL := "http://localhost" + addr if len(addr) > 0 && addr[0] != ':' { apiURL = "http://" + addr } runners := map[string]executor.Runner{ "claude": &executor.ClaudeRunner{ BinaryPath: cfg.ClaudeBinaryPath, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, }, "gemini": &executor.GeminiRunner{ BinaryPath: cfg.GeminiBinaryPath, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, }, } pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) if cfg.GeminiBinaryPath != "" { pool.Classifier = &executor.Classifier{GeminiBinaryPath: cfg.GeminiBinaryPath} } pool.RecoverStaleRunning(context.Background()) pool.RecoverStaleQueued(context.Background()) pool.RecoverStaleBlocked() srv := api.NewServer(store, pool, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath) // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} if cfg.WebhookURL != "" { notifiers = append(notifiers, notify.NewWebhookNotifier(cfg.WebhookURL, logger)) } webPushNotifier := ¬ify.WebPushNotifier{ Store: store, VAPIDPublicKey: cfg.VAPIDPublicKey, VAPIDPrivateKey: cfg.VAPIDPrivateKey, VAPIDEmail: cfg.VAPIDEmail, Logger: logger, } notifiers = append(notifiers, webPushNotifier) srv.SetNotifier(notify.NewMultiNotifier(logger, notifiers...)) srv.SetVAPIDConfig(cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDEmail) srv.SetPushStore(store) srv.SetDropsDir(cfg.DropsDir) if cfg.WorkspaceRoot != "" { srv.SetWorkspaceRoot(cfg.WorkspaceRoot) } srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) // Register scripts. wd, _ := os.Getwd() srv.SetScripts(api.ScriptRegistry{ "start-next-task": filepath.Join(wd, "scripts", "start-next-task"), "deploy": filepath.Join(wd, "scripts", "deploy"), }) srv.StartHub() httpSrv := &http.Server{ Addr: addr, Handler: srv.Handler(), } // Graceful shutdown. ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { <-sigCh logger.Info("shutting down server...") shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 5*time.Second) defer shutdownCancel() if err := httpSrv.Shutdown(shutdownCtx); err != nil { logger.Warn("shutdown error", "err", err) } }() fmt.Printf("Claudomator %s listening on %s\n", version.Version(), addr) if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { return err } return nil } // saveVAPIDToConfig appends VAPID key assignments to the config file. // It reads the existing file (if any), then writes a complete TOML file with // the new keys merged in. Uses toml encoder for correctness. func saveVAPIDToConfig(path, pub, priv string) error { existing := cfg // already loaded // Marshal the full config back including the new VAPID keys. // We use a struct alias to only encode fields we want persisted. type persistedConfig struct { DataDir string `toml:"data_dir,omitempty"` ClaudeBinaryPath string `toml:"claude_binary_path,omitempty"` GeminiBinaryPath string `toml:"gemini_binary_path,omitempty"` MaxConcurrent int `toml:"max_concurrent,omitempty"` DefaultTimeout string `toml:"default_timeout,omitempty"` ServerAddr string `toml:"server_addr,omitempty"` WebhookURL string `toml:"webhook_url,omitempty"` WorkspaceRoot string `toml:"workspace_root,omitempty"` WebhookSecret string `toml:"webhook_secret,omitempty"` VAPIDPublicKey string `toml:"vapid_public_key,omitempty"` VAPIDPrivateKey string `toml:"vapid_private_key,omitempty"` VAPIDEmail string `toml:"vapid_email,omitempty"` } pc := persistedConfig{ DataDir: existing.DataDir, ClaudeBinaryPath: existing.ClaudeBinaryPath, GeminiBinaryPath: existing.GeminiBinaryPath, MaxConcurrent: existing.MaxConcurrent, DefaultTimeout: existing.DefaultTimeout, ServerAddr: existing.ServerAddr, WebhookURL: existing.WebhookURL, WorkspaceRoot: existing.WorkspaceRoot, WebhookSecret: existing.WebhookSecret, VAPIDPublicKey: pub, VAPIDPrivateKey: priv, VAPIDEmail: existing.VAPIDEmail, } var buf bytes.Buffer if err := toml.NewEncoder(&buf).Encode(pc); err != nil { return fmt.Errorf("encoding config: %w", err) } return os.WriteFile(path, buf.Bytes(), 0600) }