diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 7fa588c5c..bf87844da 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -47,6 +47,10 @@ func NewGatewayCommand() *cobra.Command { false, "Continue starting even when no default model is configured", ) + cmd.AddCommand( + newStatusCommand(), + newStopCommand(), + ) return cmd } diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 839a7315a..dcbe9a60a 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -1,6 +1,7 @@ package gateway import ( + "slices" "testing" "github.com/stretchr/testify/assert" @@ -24,9 +25,20 @@ func TestNewGatewayCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasSubCommands()) + assert.True(t, cmd.HasSubCommands()) assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) assert.NotNil(t, cmd.Flags().Lookup("allow-empty")) + + allowedCommands := []string{ + "status", + "stop", + } + subcommands := cmd.Commands() + assert.Len(t, subcommands, len(allowedCommands)) + for _, subcmd := range subcommands { + assert.True(t, slices.Contains(allowedCommands, subcmd.Name())) + assert.NotNil(t, subcmd.RunE) + } } diff --git a/cmd/picoclaw/internal/gateway/control.go b/cmd/picoclaw/internal/gateway/control.go new file mode 100644 index 000000000..398208599 --- /dev/null +++ b/cmd/picoclaw/internal/gateway/control.go @@ -0,0 +1,75 @@ +package gateway + +import ( + "fmt" + "os" + "runtime" + "syscall" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/pid" +) + +func newStatusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show gateway process status", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return gatewayStatusCmd(internal.GetPicoclawHome()) + }, + } +} + +func newStopCommand() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop a running gateway process", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return gatewayStopCmd(internal.GetPicoclawHome()) + }, + } +} + +func gatewayStatusCmd(homePath string) error { + data := pid.ReadPidFileWithCheck(homePath) + if data == nil { + fmt.Println("Gateway status: stopped") + return nil + } + + fmt.Printf( + "Gateway status: running (PID: %d, host: %s, port: %d)\n", + data.PID, + data.Host, + data.Port, + ) + return nil +} + +func gatewayStopCmd(homePath string) error { + data := pid.ReadPidFileWithCheck(homePath) + if data == nil { + return fmt.Errorf("gateway is not running") + } + + process, err := os.FindProcess(data.PID) + if err != nil { + return fmt.Errorf("failed to find gateway process (PID: %d): %w", data.PID, err) + } + + if runtime.GOOS == "windows" { + err = process.Kill() + } else { + err = process.Signal(syscall.SIGTERM) + } + if err != nil { + return fmt.Errorf("failed to stop gateway (PID: %d): %w", data.PID, err) + } + + fmt.Printf("Sent stop signal to gateway (PID: %d)\n", data.PID) + return nil +} diff --git a/cmd/picoclaw/internal/gateway/control_test.go b/cmd/picoclaw/internal/gateway/control_test.go new file mode 100644 index 000000000..df070240b --- /dev/null +++ b/cmd/picoclaw/internal/gateway/control_test.go @@ -0,0 +1,112 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureGatewayStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + runErr := fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, copyErr := io.Copy(&buf, r) + require.NoError(t, copyErr) + + return buf.String(), runErr +} + +func writeGatewayPidFile(t *testing.T, homePath string, processID int) { + t.Helper() + + data := map[string]any{ + "pid": processID, + "token": "test-token", + "version": "test", + "host": "127.0.0.1", + "port": 18790, + } + raw, err := json.Marshal(data) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(homePath, ".picoclaw.pid"), raw, 0o600) + require.NoError(t, err) +} + +func TestGatewayStatusCmdStopped(t *testing.T) { + homePath := t.TempDir() + + output, err := captureGatewayStdout(t, func() error { + return gatewayStatusCmd(homePath) + }) + + require.NoError(t, err) + assert.Contains(t, output, "Gateway status: stopped") +} + +func TestGatewayStopCmdNotRunning(t *testing.T) { + homePath := t.TempDir() + + err := gatewayStopCmd(homePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway is not running") +} + +func TestGatewayStopCmdRunningProcess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires POSIX signal semantics") + } + + homePath := t.TempDir() + sleepCmd := exec.Command("sleep", "30") + require.NoError(t, sleepCmd.Start()) + t.Cleanup(func() { + if sleepCmd.Process != nil { + _ = sleepCmd.Process.Kill() + } + _ = sleepCmd.Wait() + }) + + writeGatewayPidFile(t, homePath, sleepCmd.Process.Pid) + + output, err := captureGatewayStdout(t, func() error { + return gatewayStopCmd(homePath) + }) + + require.NoError(t, err) + assert.Contains(t, output, "Sent stop signal to gateway") + + done := make(chan error, 1) + go func() { + done <- sleepCmd.Wait() + }() + + select { + case waitErr := <-done: + if waitErr != nil { + assert.True(t, strings.Contains(waitErr.Error(), "signal")) + } + case <-time.After(5 * time.Second): + t.Fatal("gateway process did not exit after stop signal") + } +}