feat(gateway): add CLI stop/status controls

This commit is contained in:
Alix-007 2026-04-07 10:12:03 +08:00
parent c3e7396a3d
commit d38d9fade1
4 changed files with 204 additions and 1 deletions

View file

@ -47,6 +47,10 @@ func NewGatewayCommand() *cobra.Command {
false, false,
"Continue starting even when no default model is configured", "Continue starting even when no default model is configured",
) )
cmd.AddCommand(
newStatusCommand(),
newStopCommand(),
)
return cmd return cmd
} }

View file

@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"slices"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -24,9 +25,20 @@ func TestNewGatewayCommand(t *testing.T) {
assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun) assert.Nil(t, cmd.PersistentPostRun)
assert.False(t, cmd.HasSubCommands()) assert.True(t, cmd.HasSubCommands())
assert.True(t, cmd.HasFlags()) assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("debug")) assert.NotNil(t, cmd.Flags().Lookup("debug"))
assert.NotNil(t, cmd.Flags().Lookup("allow-empty")) 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)
}
} }

View file

@ -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
}

View file

@ -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")
}
}