diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 66a56f9ce..88110de08 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -1,6 +1,8 @@ package gateway import ( + "fmt" + "github.com/spf13/cobra" ) @@ -11,7 +13,11 @@ func NewGatewayCommand() *cobra.Command { Use: "gateway", Aliases: []string{"g"}, Short: "Start picoclaw gateway", - Args: cobra.NoArgs, + Long: `Start picoclaw gateway in foreground mode. + +Use 'picoclaw gateway start' to run in background mode. +Use 'picoclaw gateway stop' to stop the background service.`, + Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { return gatewayCmd(debug) }, @@ -19,5 +25,107 @@ func NewGatewayCommand() *cobra.Command { cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + // Add subcommands + cmd.AddCommand(newStartCommand(&debug)) + cmd.AddCommand(newStopCommand()) + cmd.AddCommand(newStatusCommand()) + + return cmd +} + +func newStartCommand(debug *bool) *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start picoclaw gateway in background", + Long: `Start picoclaw gateway as a background service. + +The gateway will run in the background and logs will be written to: + ~/.picoclaw/logs/gateway.log + +Use 'picoclaw gateway stop' to stop the background service. +Use 'picoclaw gateway status' to check if the gateway is running.`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + // Check if already running + if running, pid := isGatewayRunning(); running { + fmt.Printf("âš ī¸ Gateway is already running (PID: %d)\n", pid) + fmt.Println("Use 'picoclaw gateway stop' to stop it first.") + return nil + } + + fmt.Println("🚀 Starting picoclaw gateway in background...") + + if err := startGatewayBackground(*debug); err != nil { + return fmt.Errorf("failed to start gateway: %w", err) + } + + running, pid, logPath := getGatewayStatus() + if running { + fmt.Printf("✓ Gateway started successfully (PID: %d)\n", pid) + fmt.Printf("📝 Logs: %s\n", logPath) + fmt.Println("\nUse 'picoclaw gateway stop' to stop the service.") + fmt.Println("Use 'picoclaw gateway status' to check the status.") + } else { + fmt.Println("âš ī¸ Gateway may have failed to start. Check the logs:") + fmt.Printf(" %s\n", logPath) + } + + return nil + }, + } + + return cmd +} + +func newStopCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop picoclaw gateway background service", + Long: `Stop the picoclaw gateway running in the background. + +This command will gracefully stop the gateway service that was started +with 'picoclaw gateway start'.`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + running, pid := isGatewayRunning() + if !running { + fmt.Println("â„šī¸ Gateway is not running.") + return nil + } + + fmt.Printf("🛑 Stopping picoclaw gateway (PID: %d)...\n", pid) + + if err := stopGatewayProcess(); err != nil { + return fmt.Errorf("failed to stop gateway: %w", err) + } + + fmt.Println("✓ Gateway stopped successfully.") + return nil + }, + } + + return cmd +} + +func newStatusCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show picoclaw gateway status", + Long: `Show the current status of the picoclaw gateway background service.`, + Args: cobra.NoArgs, + Run: func(_ *cobra.Command, _ []string) { + running, pid, logPath := getGatewayStatus() + + if running { + fmt.Printf("✓ Gateway is running (PID: %d)\n", pid) + fmt.Printf("📝 Logs: %s\n", logPath) + fmt.Println("\nUse 'picoclaw gateway stop' to stop the service.") + } else { + fmt.Println("○ Gateway is not running.") + fmt.Println("\nUse 'picoclaw gateway start' to start the service.") + } + }, + } + return cmd } diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 4d591ea67..34bdbb32e 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -24,8 +24,46 @@ func TestNewGatewayCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasSubCommands()) + // Gateway command now has subcommands: start, stop, status + assert.True(t, cmd.HasSubCommands()) + assert.Len(t, cmd.Commands(), 3) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) } + +func TestGatewayStartCommand(t *testing.T) { + cmd := NewGatewayCommand() + + startCmd, _, err := cmd.Find([]string{"start"}) + require.NoError(t, err) + require.NotNil(t, startCmd) + + assert.Equal(t, "start", startCmd.Use) + assert.Equal(t, "Start picoclaw gateway in background", startCmd.Short) + assert.NotNil(t, startCmd.RunE) +} + +func TestGatewayStopCommand(t *testing.T) { + cmd := NewGatewayCommand() + + stopCmd, _, err := cmd.Find([]string{"stop"}) + require.NoError(t, err) + require.NotNil(t, stopCmd) + + assert.Equal(t, "stop", stopCmd.Use) + assert.Equal(t, "Stop picoclaw gateway background service", stopCmd.Short) + assert.NotNil(t, stopCmd.RunE) +} + +func TestGatewayStatusCommand(t *testing.T) { + cmd := NewGatewayCommand() + + statusCmd, _, err := cmd.Find([]string{"status"}) + require.NoError(t, err) + require.NotNil(t, statusCmd) + + assert.Equal(t, "status", statusCmd.Use) + assert.Equal(t, "Show picoclaw gateway status", statusCmd.Short) + assert.NotNil(t, statusCmd.Run) +} diff --git a/cmd/picoclaw/internal/gateway/process.go b/cmd/picoclaw/internal/gateway/process.go new file mode 100644 index 000000000..677c0ed11 --- /dev/null +++ b/cmd/picoclaw/internal/gateway/process.go @@ -0,0 +1,181 @@ +package gateway + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "syscall" +) + +// getPidFilePath returns the path to the gateway PID file +func getPidFilePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw", "gateway.pid") +} + +// savePID saves the process ID to the PID file +func savePID(pid int) error { + pidFile := getPidFilePath() + if err := os.MkdirAll(filepath.Dir(pidFile), 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + return os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0o644) +} + +// loadPID loads the process ID from the PID file +func loadPID() (int, error) { + data, err := os.ReadFile(getPidFilePath()) + if err != nil { + return 0, err + } + return strconv.Atoi(string(data)) +} + +// removePIDFile removes the PID file +func removePIDFile() { + _ = os.Remove(getPidFilePath()) +} + +// isProcessRunning checks if a process with the given PID is running +func isProcessRunning(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // On Unix systems, FindProcess always succeeds, so we need to send signal 0 to check + if runtime.GOOS != "windows" { + err = process.Signal(syscall.Signal(0)) + return err == nil + } + + // On Windows, we rely on FindProcess result + return true +} + +// isGatewayRunning checks if the gateway is currently running +func isGatewayRunning() (bool, int) { + pid, err := loadPID() + if err != nil { + return false, 0 + } + + if isProcessRunning(pid) { + return true, pid + } + + // PID file exists but process is not running, clean up + removePIDFile() + return false, 0 +} + +// startGatewayBackground starts the gateway in the background +func startGatewayBackground(debug bool) error { + // Check if already running + if running, pid := isGatewayRunning(); running { + return fmt.Errorf("gateway is already running (PID: %d)", pid) + } + + // Get the current executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Build command arguments + args := []string{"gateway"} + if debug { + args = append(args, "--debug") + } + + cmd := exec.Command(execPath, args...) + + // Redirect output to log file + logPath := getLogFilePath() + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + + cmd.Stdout = logFile + cmd.Stderr = logFile + + // Set process group for proper cleanup on Unix systems + if runtime.GOOS != "windows" { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + } + + // Start the process + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return fmt.Errorf("failed to start gateway: %w", err) + } + + // Close log file handle (process inherits it) + _ = logFile.Close() + + // Save PID + if err := savePID(cmd.Process.Pid); err != nil { + // Try to kill the process if we can't save the PID + _ = cmd.Process.Kill() + return fmt.Errorf("failed to save PID: %w", err) + } + + return nil +} + +// stopGatewayProcess stops the running gateway process +func stopGatewayProcess() error { + running, pid := isGatewayRunning() + if !running { + return fmt.Errorf("gateway is not running") + } + + process, err := os.FindProcess(pid) + if err != nil { + removePIDFile() + return fmt.Errorf("failed to find process: %w", err) + } + + // Send interrupt signal first for graceful shutdown + if runtime.GOOS != "windows" { + if err := process.Signal(os.Interrupt); err != nil { + // If interrupt fails, try kill + if killErr := process.Kill(); killErr != nil { + removePIDFile() + return fmt.Errorf("failed to stop gateway: %w", killErr) + } + } + } else { + // Windows doesn't support Interrupt, use Kill directly + if err := process.Kill(); err != nil { + removePIDFile() + return fmt.Errorf("failed to stop gateway: %w", err) + } + } + + removePIDFile() + return nil +} + +// getLogFilePath returns the path to the gateway log file +func getLogFilePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".picoclaw", "logs", "gateway.log") +} + +// getGatewayStatus returns the status information of the gateway +func getGatewayStatus() (bool, int, string) { + running, pid := isGatewayRunning() + logPath := getLogFilePath() + return running, pid, logPath +}