feat: add service command (install/uninstall/start/stop/restart/status/logs)
- Add pkg/service with launchd (macOS) and systemd user (Linux) backends - Add picoclaw service install|uninstall|start|stop|restart|status|logs|refresh - service logs: support -f/--follow for tailing; -n/--lines for line count - main: add service case, invokedCLIName(), printHelp entry for service - gateway: optional check to avoid double-run when service already running Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
18ba88869a
commit
85c94a55ce
15 changed files with 1086 additions and 2 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -26,6 +27,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
svcmgr "github.com/sipeed/picoclaw/pkg/service"
|
||||
)
|
||||
|
||||
func gatewayCmd() {
|
||||
|
|
@ -39,6 +41,26 @@ func gatewayCmd() {
|
|||
}
|
||||
}
|
||||
|
||||
// Avoid double-running when the managed service is already active.
|
||||
if os.Getenv("XPC_SERVICE_NAME") == "" && os.Getenv("INVOCATION_ID") == "" {
|
||||
exePath, err := resolveServiceExecutablePath(os.Args[0], exec.LookPath, os.Executable)
|
||||
if err == nil {
|
||||
if mgr, mgrErr := svcmgr.NewManager(exePath); mgrErr == nil {
|
||||
if st, statusErr := mgr.Status(); statusErr == nil && st.Running {
|
||||
backend := strings.TrimSpace(st.Backend)
|
||||
if backend == "" {
|
||||
backend = mgr.Backend()
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Gateway is already running via %s service.\n", backend)
|
||||
fmt.Fprintf(os.Stderr, " Stop it first: %s service stop\n", invokedCLIName())
|
||||
fmt.Fprintf(os.Stderr, " View logs: %s service logs\n", invokedCLIName())
|
||||
fmt.Fprintf(os.Stderr, " Restart: %s service restart\n", invokedCLIName())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
|
|
@ -107,6 +108,8 @@ func main() {
|
|||
agentCmd()
|
||||
case "gateway":
|
||||
gatewayCmd()
|
||||
case "service":
|
||||
serviceCmd()
|
||||
case "status":
|
||||
statusCmd()
|
||||
case "migrate":
|
||||
|
|
@ -182,6 +185,7 @@ func printHelp() {
|
|||
fmt.Println(" agent Interact with the agent directly")
|
||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||
fmt.Println(" gateway Start picoclaw gateway")
|
||||
fmt.Println(" service Manage background gateway service (launchd/systemd)")
|
||||
fmt.Println(" status Show picoclaw status")
|
||||
fmt.Println(" cron Manage scheduled tasks")
|
||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||
|
|
@ -197,3 +201,13 @@ func getConfigPath() string {
|
|||
func loadConfig() (*config.Config, error) {
|
||||
return config.LoadConfig(getConfigPath())
|
||||
}
|
||||
|
||||
func invokedCLIName() string {
|
||||
if len(os.Args) > 0 {
|
||||
name := filepath.Base(os.Args[0])
|
||||
if strings.TrimSpace(name) != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "picoclaw"
|
||||
}
|
||||
|
|
|
|||
271
cmd/picoclaw/service_cmd.go
Normal file
271
cmd/picoclaw/service_cmd.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
svcmgr "github.com/sipeed/picoclaw/pkg/service"
|
||||
)
|
||||
|
||||
type serviceLogsOptions struct {
|
||||
Lines int
|
||||
Follow bool
|
||||
}
|
||||
|
||||
func serviceCmd() {
|
||||
args := os.Args[2:]
|
||||
if len(args) == 0 {
|
||||
serviceHelp()
|
||||
return
|
||||
}
|
||||
|
||||
sub := strings.ToLower(args[0])
|
||||
if sub == "help" || sub == "--help" || sub == "-h" {
|
||||
serviceHelp()
|
||||
return
|
||||
}
|
||||
|
||||
exePath, err := resolveServiceExecutablePath(os.Args[0], exec.LookPath, os.Executable)
|
||||
if err != nil {
|
||||
fmt.Printf("Error resolving executable path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mgr, err := svcmgr.NewManager(exePath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error initializing service manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch sub {
|
||||
case "install":
|
||||
prepareServiceInstallEnvPath()
|
||||
if err := mgr.Install(); err != nil {
|
||||
fmt.Printf("Service install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✓ Service installed")
|
||||
fmt.Printf(" Start with: %s service start\n", invokedCLIName())
|
||||
case "refresh":
|
||||
if err := runServiceRefresh(mgr); err != nil {
|
||||
fmt.Printf("Service refresh failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✓ Service refreshed")
|
||||
fmt.Printf(" Reinstalled and restarted (run: %s service status)\n", invokedCLIName())
|
||||
case "uninstall", "remove":
|
||||
if err := mgr.Uninstall(); err != nil {
|
||||
fmt.Printf("Service uninstall failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✓ Service uninstalled")
|
||||
case "start":
|
||||
if err := mgr.Start(); err != nil {
|
||||
fmt.Printf("Service start failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✓ Service started")
|
||||
case "stop":
|
||||
if err := mgr.Stop(); err != nil {
|
||||
fmt.Printf("Service stop failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✓ Service stopped")
|
||||
case "restart":
|
||||
if err := mgr.Restart(); err != nil {
|
||||
fmt.Printf("Service restart failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✓ Service restarted")
|
||||
case "status":
|
||||
st, err := mgr.Status()
|
||||
if err != nil {
|
||||
fmt.Printf("Service status check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printServiceStatus(st)
|
||||
case "logs":
|
||||
opts, showHelp, err := parseServiceLogsOptions(args[1:])
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
serviceHelp()
|
||||
os.Exit(2)
|
||||
}
|
||||
if showHelp {
|
||||
serviceHelp()
|
||||
return
|
||||
}
|
||||
if opts.Follow {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := mgr.LogsFollow(ctx, opts.Lines, os.Stdout); err != nil && ctx.Err() == nil {
|
||||
fmt.Fprintf(os.Stderr, "Service logs failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
out, err := mgr.Logs(opts.Lines)
|
||||
if err != nil {
|
||||
fmt.Printf("Service logs failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown service command: %s\n", sub)
|
||||
serviceHelp()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareServiceInstallEnvPath() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
venvBin := strings.TrimSpace(workspaceVenvBinDir(cfg.WorkspacePath()))
|
||||
if venvBin == "" {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(venvBin); err != nil {
|
||||
return
|
||||
}
|
||||
prependPathEnv(venvBin)
|
||||
}
|
||||
|
||||
func prependPathEnv(pathEntry string) {
|
||||
pathEntry = strings.TrimSpace(pathEntry)
|
||||
if pathEntry == "" {
|
||||
return
|
||||
}
|
||||
sep := string(os.PathListSeparator)
|
||||
current := os.Getenv("PATH")
|
||||
parts := []string{pathEntry}
|
||||
for _, p := range strings.Split(current, sep) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == pathEntry {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, p)
|
||||
}
|
||||
_ = os.Setenv("PATH", strings.Join(parts, sep))
|
||||
}
|
||||
|
||||
func runServiceRefresh(mgr svcmgr.Manager) error {
|
||||
prepareServiceInstallEnvPath()
|
||||
if err := mgr.Install(); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
if err := mgr.Restart(); err != nil {
|
||||
return fmt.Errorf("restart failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveServiceExecutablePath(
|
||||
argv0 string,
|
||||
lookPath func(string) (string, error),
|
||||
executable func() (string, error),
|
||||
) (string, error) {
|
||||
arg0 := strings.TrimSpace(argv0)
|
||||
|
||||
if arg0 != "" && (strings.Contains(arg0, "/") || strings.Contains(arg0, `\`)) {
|
||||
if abs, err := filepath.Abs(arg0); err == nil {
|
||||
return abs, nil
|
||||
}
|
||||
return arg0, nil
|
||||
}
|
||||
|
||||
base := strings.TrimSpace(filepath.Base(arg0))
|
||||
if base != "" {
|
||||
if resolved, err := lookPath(base); err == nil && strings.TrimSpace(resolved) != "" {
|
||||
if abs, err := filepath.Abs(resolved); err == nil {
|
||||
return abs, nil
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
|
||||
return executable()
|
||||
}
|
||||
|
||||
func serviceHelp() {
|
||||
commandName := invokedCLIName()
|
||||
fmt.Println("\nService commands:")
|
||||
fmt.Println(" install Install background gateway service")
|
||||
fmt.Println(" refresh Reinstall + restart service after upgrades")
|
||||
fmt.Println(" uninstall Remove background gateway service")
|
||||
fmt.Println(" start Start background gateway service")
|
||||
fmt.Println(" stop Stop background gateway service")
|
||||
fmt.Println(" restart Restart background gateway service")
|
||||
fmt.Println(" status Show service install/runtime status")
|
||||
fmt.Println(" logs Show recent service logs")
|
||||
fmt.Println()
|
||||
fmt.Println("Logs options:")
|
||||
fmt.Println(" -n, --lines <N> Number of log lines to show (default: 100)")
|
||||
fmt.Println(" -f, --follow Follow log output (like tail -f); Ctrl+C to stop")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Printf(" %s service install\n", commandName)
|
||||
fmt.Printf(" %s service refresh\n", commandName)
|
||||
fmt.Printf(" %s service start\n", commandName)
|
||||
fmt.Printf(" %s service status\n", commandName)
|
||||
fmt.Printf(" %s service logs --lines 200\n", commandName)
|
||||
fmt.Printf(" %s service logs -f\n", commandName)
|
||||
}
|
||||
|
||||
func parseServiceLogsOptions(args []string) (serviceLogsOptions, bool, error) {
|
||||
opts := serviceLogsOptions{Lines: 100}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-n", "--lines":
|
||||
if i+1 >= len(args) {
|
||||
return opts, false, fmt.Errorf("%s requires a value", args[i])
|
||||
}
|
||||
n, err := strconv.Atoi(args[i+1])
|
||||
if err != nil || n <= 0 {
|
||||
return opts, false, fmt.Errorf("invalid value for %s: %q", args[i], args[i+1])
|
||||
}
|
||||
opts.Lines = n
|
||||
i++
|
||||
case "-f", "--follow":
|
||||
opts.Follow = true
|
||||
case "help", "--help", "-h":
|
||||
return opts, true, nil
|
||||
default:
|
||||
return opts, false, fmt.Errorf("unknown option: %s", args[i])
|
||||
}
|
||||
}
|
||||
return opts, false, nil
|
||||
}
|
||||
|
||||
func printServiceStatus(st svcmgr.Status) {
|
||||
yn := func(v bool) string {
|
||||
if v {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
fmt.Println("\nGateway service status:")
|
||||
fmt.Printf(" Backend: %s\n", st.Backend)
|
||||
fmt.Printf(" Installed: %s\n", yn(st.Installed))
|
||||
fmt.Printf(" Running: %s\n", yn(st.Running))
|
||||
fmt.Printf(" Enabled: %s\n", yn(st.Enabled))
|
||||
if strings.TrimSpace(st.Detail) != "" {
|
||||
fmt.Printf(" Detail: %s\n", st.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceVenvBinDir(workspace string) string {
|
||||
if strings.TrimSpace(workspace) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(workspace, ".venv", "bin")
|
||||
}
|
||||
23
pkg/service/detect.go
Normal file
23
pkg/service/detect.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func detectWSL() bool {
|
||||
return detectWSLWith(os.Getenv, os.ReadFile)
|
||||
}
|
||||
|
||||
func detectWSLWith(getenv func(string) string, readFile func(string) ([]byte, error)) bool {
|
||||
for _, key := range []string{"WSL_DISTRO_NAME", "WSL_INTEROP"} {
|
||||
if strings.TrimSpace(getenv(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
b, err := readFile("/proc/version")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(b)), "microsoft")
|
||||
}
|
||||
222
pkg/service/launchd_darwin.go
Normal file
222
pkg/service/launchd_darwin.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//go:build darwin
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type launchdManager struct {
|
||||
runner commandRunner
|
||||
exePath string
|
||||
label string
|
||||
domainTarget string
|
||||
serviceTarget string
|
||||
plistPath string
|
||||
stdoutPath string
|
||||
stderrPath string
|
||||
}
|
||||
|
||||
func newLaunchdManager(exePath string, runner commandRunner) Manager {
|
||||
home, _ := os.UserHomeDir()
|
||||
label := "io.picoclaw.gateway"
|
||||
domain := fmt.Sprintf("gui/%d", os.Getuid())
|
||||
serviceTarget := fmt.Sprintf("%s/%s", domain, label)
|
||||
return &launchdManager{
|
||||
runner: runner,
|
||||
exePath: exePath,
|
||||
label: label,
|
||||
domainTarget: domain,
|
||||
serviceTarget: serviceTarget,
|
||||
plistPath: filepath.Join(home, "Library", "LaunchAgents", label+".plist"),
|
||||
stdoutPath: filepath.Join(home, ".picoclaw", "gateway.log"),
|
||||
stderrPath: filepath.Join(home, ".picoclaw", "gateway.err.log"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *launchdManager) Backend() string { return BackendLaunchd }
|
||||
|
||||
func (m *launchdManager) Install() error {
|
||||
_ = m.Uninstall()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(m.plistPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.stdoutPath), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pathEnv := buildSystemdPath(os.Getenv("PATH"), m.detectBrewPrefix())
|
||||
plist := renderLaunchdPlist(m.label, m.exePath, m.stdoutPath, m.stderrPath, pathEnv)
|
||||
if err := os.WriteFile(m.plistPath, []byte(plist), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
out, err := runCommand(m.runner, 10*time.Second, "launchctl", "bootstrap", m.domainTarget, m.plistPath)
|
||||
if err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
msg := strings.ToLower(string(out))
|
||||
if strings.Contains(msg, "already bootstrapped") {
|
||||
lastErr = nil
|
||||
break
|
||||
}
|
||||
lastErr = fmt.Errorf("bootstrap failed: %s", oneLine(string(out)))
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
if out, err := runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget); err != nil {
|
||||
return fmt.Errorf("enable failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *launchdManager) Uninstall() error {
|
||||
_, _ = runCommand(m.runner, 10*time.Second, "launchctl", "bootout", m.serviceTarget)
|
||||
_, _ = runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget)
|
||||
if err := os.Remove(m.plistPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *launchdManager) Start() error {
|
||||
if _, err := os.Stat(m.plistPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("service is not installed; run `picoclaw service install`")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget)
|
||||
if _, err := runCommand(m.runner, 3*time.Second, "launchctl", "print", m.serviceTarget); err != nil {
|
||||
if out, err2 := runCommand(m.runner, 10*time.Second, "launchctl", "bootstrap", m.domainTarget, m.plistPath); err2 != nil {
|
||||
msg := strings.ToLower(string(out))
|
||||
if !strings.Contains(msg, "already bootstrapped") {
|
||||
return fmt.Errorf("bootstrap failed: %s", oneLine(string(out)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if out, err := runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget); err != nil {
|
||||
return fmt.Errorf("enable failed: %s", commandErrorDetail(err, out))
|
||||
}
|
||||
if out, err := runCommand(m.runner, 10*time.Second, "launchctl", "kickstart", "-k", m.serviceTarget); err != nil {
|
||||
if st, stErr := m.Status(); stErr == nil && st.Running {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("kickstart failed: %s", commandErrorDetail(err, out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *launchdManager) Stop() error {
|
||||
if out, err := runCommand(m.runner, 10*time.Second, "launchctl", "bootout", m.serviceTarget); err != nil {
|
||||
msg := strings.ToLower(string(out))
|
||||
if strings.Contains(msg, "could not find service") || strings.Contains(msg, "no such process") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("bootout failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *launchdManager) Restart() error {
|
||||
if err := m.Stop(); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Start()
|
||||
}
|
||||
|
||||
func (m *launchdManager) Status() (Status, error) {
|
||||
st := Status{Backend: BackendLaunchd}
|
||||
if _, err := os.Stat(m.plistPath); err == nil {
|
||||
st.Installed = true
|
||||
st.Enabled = true
|
||||
}
|
||||
out, err := runCommand(m.runner, 3*time.Second, "launchctl", "print", m.serviceTarget)
|
||||
if err == nil {
|
||||
text := strings.ToLower(string(out))
|
||||
if strings.Contains(text, "state = running") || hasNonZeroPID(text) {
|
||||
st.Running = true
|
||||
}
|
||||
st.Detail = oneLine(string(out))
|
||||
return st, nil
|
||||
}
|
||||
if st.Installed {
|
||||
st.Detail = "installed but not loaded"
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (m *launchdManager) Logs(lines int) (string, error) {
|
||||
sections := map[string]string{}
|
||||
if out, err := tailFileLines(m.stdoutPath, lines); err == nil {
|
||||
sections[m.stdoutPath] = out
|
||||
}
|
||||
if out, err := tailFileLines(m.stderrPath, lines); err == nil {
|
||||
sections[m.stderrPath] = out
|
||||
}
|
||||
combined := combineLogSections(sections)
|
||||
if strings.TrimSpace(combined) == "" {
|
||||
return "", fmt.Errorf("no launchd logs found at %s or %s", m.stdoutPath, m.stderrPath)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func (m *launchdManager) LogsFollow(ctx context.Context, lines int, w io.Writer) error {
|
||||
n := fmt.Sprintf("%d", lines)
|
||||
if lines <= 0 {
|
||||
n = "100"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "tail", "-n", n, "-f", m.stdoutPath, m.stderrPath)
|
||||
cmd.Stdout = w
|
||||
cmd.Stderr = w
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func hasNonZeroPID(text string) bool {
|
||||
idx := strings.Index(text, "pid = ")
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimSpace(text[idx+len("pid = "):])
|
||||
if len(rest) > 0 && rest[0] != '0' && rest[0] >= '1' && rest[0] <= '9' {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandErrorDetail(err error, out []byte) string {
|
||||
if msg := oneLine(string(out)); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *launchdManager) detectBrewPrefix() string {
|
||||
if _, err := exec.LookPath("brew"); err != nil {
|
||||
return ""
|
||||
}
|
||||
out, err := runCommand(m.runner, 4*time.Second, "brew", "--prefix")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
7
pkg/service/launchd_stub.go
Normal file
7
pkg/service/launchd_stub.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !darwin
|
||||
|
||||
package service
|
||||
|
||||
func newLaunchdManager(exePath string, runner commandRunner) Manager {
|
||||
return newUnsupportedManager("launchd is only available on macOS")
|
||||
}
|
||||
47
pkg/service/logs.go
Normal file
47
pkg/service/logs.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func tailFileLines(path string, lines int) (string, error) {
|
||||
if lines <= 0 {
|
||||
lines = 100
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
txt := strings.TrimRight(string(b), "\n")
|
||||
if txt == "" {
|
||||
return "", nil
|
||||
}
|
||||
all := strings.Split(txt, "\n")
|
||||
if len(all) <= lines {
|
||||
return strings.Join(all, "\n") + "\n", nil
|
||||
}
|
||||
return strings.Join(all[len(all)-lines:], "\n") + "\n", nil
|
||||
}
|
||||
|
||||
func combineLogSections(sections map[string]string) string {
|
||||
out := ""
|
||||
keys := make([]string, 0, len(sections))
|
||||
for name := range sections {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, name := range keys {
|
||||
text := sections[name]
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
if out != "" {
|
||||
out += "\n"
|
||||
}
|
||||
out += fmt.Sprintf("==> %s <==\n%s", name, text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
121
pkg/service/manager.go
Normal file
121
pkg/service/manager.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BackendLaunchd = "launchd"
|
||||
BackendSystemdUser = "systemd-user"
|
||||
BackendUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// Status captures installation and runtime state for the background gateway service.
|
||||
type Status struct {
|
||||
Backend string
|
||||
Installed bool
|
||||
Running bool
|
||||
Enabled bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Manager controls the background gateway service for the current platform.
|
||||
type Manager interface {
|
||||
Backend() string
|
||||
Install() error
|
||||
Uninstall() error
|
||||
Start() error
|
||||
Stop() error
|
||||
Restart() error
|
||||
Status() (Status, error)
|
||||
Logs(lines int) (string, error)
|
||||
// LogsFollow streams log output to w until ctx is done (e.g. SIGINT). Follows new lines like tail -f.
|
||||
LogsFollow(ctx context.Context, lines int, w io.Writer) error
|
||||
}
|
||||
|
||||
type commandRunner interface {
|
||||
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
type osCommandRunner struct{}
|
||||
|
||||
func (osCommandRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
return exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
}
|
||||
|
||||
func NewManager(exePath string) (Manager, error) {
|
||||
exePath = strings.TrimSpace(exePath)
|
||||
if exePath == "" {
|
||||
return nil, errors.New("executable path is empty")
|
||||
}
|
||||
|
||||
runner := osCommandRunner{}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return newLaunchdManager(exePath, runner), nil
|
||||
case "linux":
|
||||
if detectWSL() && !isSystemdUserAvailable(runner) {
|
||||
return newUnsupportedManager("WSL detected but systemd user manager is not active. Enable systemd in /etc/wsl.conf or run `picoclaw gateway` in a terminal."), nil
|
||||
}
|
||||
if !isSystemdUserAvailable(runner) {
|
||||
return newUnsupportedManager("systemd user manager is not available. Run `picoclaw gateway` in a terminal."), nil
|
||||
}
|
||||
return newSystemdUserManager(exePath, runner), nil
|
||||
default:
|
||||
return newUnsupportedManager(fmt.Sprintf("%s is not currently supported for service management", runtime.GOOS)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func isSystemdUserAvailable(runner commandRunner) bool {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err := runner.Run(ctx, "systemctl", "--user", "show-environment")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func runCommand(runner commandRunner, timeout time.Duration, name string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := runner.Run(ctx, name, args...)
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return out, fmt.Errorf("%s %s timed out", name, strings.Join(args, " "))
|
||||
}
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func writeFileIfChanged(path string, content []byte, perm os.FileMode) error {
|
||||
if existing, err := os.ReadFile(path); err == nil {
|
||||
if string(existing) == string(content) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, content, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " "))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if len(s) > 220 {
|
||||
return s[:220] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
74
pkg/service/path.go
Normal file
74
pkg/service/path.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildSystemdPath(installerPath, brewPrefix string) string {
|
||||
paths := make([]string, 0, 24)
|
||||
|
||||
// Deterministic baseline first.
|
||||
for _, p := range []string{
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/local/sbin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
} {
|
||||
paths = appendUniquePath(paths, p)
|
||||
}
|
||||
|
||||
// Known Homebrew/Linuxbrew locations.
|
||||
for _, p := range []string{
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/home/linuxbrew/.linuxbrew/bin",
|
||||
"/home/linuxbrew/.linuxbrew/sbin",
|
||||
} {
|
||||
paths = appendUniquePath(paths, p)
|
||||
}
|
||||
|
||||
// If brew --prefix resolves, include it explicitly.
|
||||
if strings.TrimSpace(brewPrefix) != "" {
|
||||
paths = appendUniquePath(paths, filepath.Join(brewPrefix, "bin"))
|
||||
paths = appendUniquePath(paths, filepath.Join(brewPrefix, "sbin"))
|
||||
}
|
||||
|
||||
// Managed workspace virtualenv location.
|
||||
for _, p := range managedVenvBinCandidates() {
|
||||
paths = appendUniquePath(paths, p)
|
||||
}
|
||||
|
||||
// Include PATH from the shell running install for custom bins.
|
||||
for _, p := range strings.Split(installerPath, string(os.PathListSeparator)) {
|
||||
paths = appendUniquePath(paths, p)
|
||||
}
|
||||
|
||||
return strings.Join(paths, string(os.PathListSeparator))
|
||||
}
|
||||
|
||||
func managedVenvBinCandidates() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{
|
||||
filepath.Join(home, ".picoclaw", "workspace", ".venv", "bin"),
|
||||
}
|
||||
}
|
||||
|
||||
func appendUniquePath(paths []string, path string) []string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return paths
|
||||
}
|
||||
for _, existing := range paths {
|
||||
if existing == trimmed {
|
||||
return paths
|
||||
}
|
||||
}
|
||||
return append(paths, trimmed)
|
||||
}
|
||||
160
pkg/service/systemd_linux.go
Normal file
160
pkg/service/systemd_linux.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//go:build linux
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type systemdUserManager struct {
|
||||
runner commandRunner
|
||||
exePath string
|
||||
unitName string
|
||||
unitPath string
|
||||
journalID string
|
||||
}
|
||||
|
||||
func newSystemdUserManager(exePath string, runner commandRunner) Manager {
|
||||
home, _ := os.UserHomeDir()
|
||||
unitName := "picoclaw-gateway.service"
|
||||
return &systemdUserManager{
|
||||
runner: runner,
|
||||
exePath: exePath,
|
||||
unitName: unitName,
|
||||
unitPath: filepath.Join(home, ".config", "systemd", "user", unitName),
|
||||
journalID: unitName,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Backend() string { return BackendSystemdUser }
|
||||
|
||||
func (m *systemdUserManager) Install() error {
|
||||
if err := os.MkdirAll(filepath.Dir(m.unitPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
pathEnv := buildSystemdPath(os.Getenv("PATH"), m.detectBrewPrefix())
|
||||
unit := renderSystemdUnit(m.exePath, pathEnv)
|
||||
if err := writeFileIfChanged(m.unitPath, []byte(unit), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if out, err := runCommand(m.runner, 8*time.Second, "systemctl", "--user", "daemon-reload"); err != nil {
|
||||
return fmt.Errorf("daemon-reload failed: %s", oneLine(string(out)))
|
||||
}
|
||||
if out, err := runCommand(m.runner, 8*time.Second, "systemctl", "--user", "enable", m.unitName); err != nil {
|
||||
return fmt.Errorf("enable failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Uninstall() error {
|
||||
_, _ = runCommand(m.runner, 8*time.Second, "systemctl", "--user", "disable", "--now", m.unitName)
|
||||
if err := os.Remove(m.unitPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
_, _ = runCommand(m.runner, 8*time.Second, "systemctl", "--user", "daemon-reload")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Start() error {
|
||||
if _, err := os.Stat(m.unitPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("service is not installed; run `picoclaw service install`")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if out, err := runCommand(m.runner, 12*time.Second, "systemctl", "--user", "start", m.unitName); err != nil {
|
||||
return fmt.Errorf("start failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Stop() error {
|
||||
if out, err := runCommand(m.runner, 12*time.Second, "systemctl", "--user", "stop", m.unitName); err != nil {
|
||||
msg := strings.ToLower(string(out))
|
||||
if strings.Contains(msg, "not loaded") || strings.Contains(msg, "could not be found") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stop failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Restart() error {
|
||||
if _, err := os.Stat(m.unitPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("service is not installed; run `picoclaw service install`")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if out, err := runCommand(m.runner, 12*time.Second, "systemctl", "--user", "restart", m.unitName); err != nil {
|
||||
return fmt.Errorf("restart failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Status() (Status, error) {
|
||||
st := Status{Backend: BackendSystemdUser}
|
||||
if _, err := os.Stat(m.unitPath); err == nil {
|
||||
st.Installed = true
|
||||
}
|
||||
|
||||
if out, err := runCommand(m.runner, 5*time.Second, "systemctl", "--user", "is-active", m.unitName); err == nil {
|
||||
if strings.TrimSpace(string(out)) == "active" {
|
||||
st.Running = true
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := runCommand(m.runner, 5*time.Second, "systemctl", "--user", "is-enabled", m.unitName); err == nil {
|
||||
en := strings.TrimSpace(string(out))
|
||||
if strings.HasPrefix(en, "enabled") {
|
||||
st.Enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
if !st.Installed {
|
||||
st.Detail = "unit file not installed"
|
||||
} else if !st.Running {
|
||||
st.Detail = "installed but not running"
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) Logs(lines int) (string, error) {
|
||||
if lines <= 0 {
|
||||
lines = 100
|
||||
}
|
||||
out, err := runCommand(m.runner, 10*time.Second, "journalctl", "--user", "-u", m.journalID, "-n", fmt.Sprintf("%d", lines), "--no-pager")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("journalctl failed: %s", oneLine(string(out)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) LogsFollow(ctx context.Context, lines int, w io.Writer) error {
|
||||
n := lines
|
||||
if n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "journalctl", "--user", "-u", m.journalID, "-n", fmt.Sprintf("%d", n), "-f", "--no-pager")
|
||||
cmd.Stdout = w
|
||||
cmd.Stderr = w
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (m *systemdUserManager) detectBrewPrefix() string {
|
||||
if _, err := exec.LookPath("brew"); err != nil {
|
||||
return ""
|
||||
}
|
||||
out, err := runCommand(m.runner, 4*time.Second, "brew", "--prefix")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
7
pkg/service/systemd_stub.go
Normal file
7
pkg/service/systemd_stub.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !linux
|
||||
|
||||
package service
|
||||
|
||||
func newSystemdUserManager(exePath string, runner commandRunner) Manager {
|
||||
return newUnsupportedManager("systemd user services are only available on Linux")
|
||||
}
|
||||
59
pkg/service/templates.go
Normal file
59
pkg/service/templates.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package service
|
||||
|
||||
import "fmt"
|
||||
|
||||
func renderSystemdUnit(exePath, pathEnv string) string {
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=PicoClaw Gateway
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s gateway
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=HOME=%%h
|
||||
Environment=PATH=%s
|
||||
WorkingDirectory=%%h
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`, exePath, pathEnv)
|
||||
}
|
||||
|
||||
func renderLaunchdPlist(label, exePath, stdoutPath, stderrPath, pathEnv string) string {
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>%s</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
<string>gateway</string>
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>%s</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>%s</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>%s</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`, label, exePath, stdoutPath, stderrPath, pathEnv)
|
||||
}
|
||||
52
pkg/service/unsupported.go
Normal file
52
pkg/service/unsupported.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type unsupportedManager struct {
|
||||
detail string
|
||||
}
|
||||
|
||||
func newUnsupportedManager(detail string) Manager {
|
||||
return &unsupportedManager{detail: detail}
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Backend() string { return BackendUnsupported }
|
||||
|
||||
func (m *unsupportedManager) Install() error {
|
||||
return fmt.Errorf("service management unavailable: %s", m.detail)
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Uninstall() error {
|
||||
return fmt.Errorf("service management unavailable: %s", m.detail)
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Start() error {
|
||||
return fmt.Errorf("service management unavailable: %s", m.detail)
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Stop() error {
|
||||
return fmt.Errorf("service management unavailable: %s", m.detail)
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Restart() error {
|
||||
return fmt.Errorf("service management unavailable: %s", m.detail)
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Status() (Status, error) {
|
||||
return Status{
|
||||
Backend: BackendUnsupported,
|
||||
Detail: m.detail,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) Logs(lines int) (string, error) {
|
||||
return "", fmt.Errorf("service logs unavailable: %s", m.detail)
|
||||
}
|
||||
|
||||
func (m *unsupportedManager) LogsFollow(ctx context.Context, lines int, w io.Writer) error {
|
||||
return fmt.Errorf("service logs unavailable: %s", m.detail)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package tools
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
|
@ -229,6 +230,9 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
|||
|
||||
entries, err := t.fs.ReadDir(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrorResult(fmt.Sprintf("directory does not exist: %s", path))
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
||||
}
|
||||
return formatDirEntries(entries)
|
||||
|
|
|
|||
|
|
@ -232,8 +232,9 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
|||
t.Errorf("Expected error for non-existent directory, got IsError=false")
|
||||
}
|
||||
|
||||
// Should contain error message
|
||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||
// Should contain error message (either "directory does not exist" or "failed to read")
|
||||
msg := result.ForLLM + result.ForUser
|
||||
if !strings.Contains(msg, "directory does not exist") && !strings.Contains(msg, "failed to read") {
|
||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue