package main import ( "bufio" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" ) // Status 服务状态快照。 type Status struct { Running bool PID int Restarts int StartedAt time.Time LastError string URL string Updating bool } // Supervisor 守护 dsh web:隐藏终端启动,异常退出自动重启。 type Supervisor struct { mu sync.Mutex cfg *Config log *Logger cmd *exec.Cmd pid int running bool desired bool restarts int startedAt time.Time lastError string updating bool binCache string nodeCache string webURL string // dsh web 输出的完整 URL(含访问 token) } func NewSupervisor(cfg *Config, log *Logger) *Supervisor { return &Supervisor{cfg: cfg, log: log} } // Start 设置期望运行并拉起守护循环。 func (s *Supervisor) Start() { s.mu.Lock() if s.desired { s.mu.Unlock() return } s.desired = true s.mu.Unlock() go s.monitor() } // Stop 停止守护并结束子进程树。 func (s *Supervisor) Stop() { s.mu.Lock() s.desired = false pid := s.pid s.webURL = "" s.mu.Unlock() if pid > 0 { s.log.Printf("停止 dsh web pid=%d", pid) _ = killTree(pid) } } // Restart 重启服务。 func (s *Supervisor) Restart() { s.log.Printf("重启 dsh web") s.Stop() time.Sleep(500 * time.Millisecond) s.Start() } func (s *Supervisor) Status() Status { s.mu.Lock() defer s.mu.Unlock() host := s.cfg.WebHost if host == "" { host = "127.0.0.1" } url := fmt.Sprintf("http://%s:%d", host, s.cfg.WebPort) if s.webURL != "" { url = s.webURL // 优先使用 dsh web 输出的带 token URL } return Status{ Running: s.running, PID: s.pid, Restarts: s.restarts, StartedAt: s.startedAt, LastError: s.lastError, URL: url, Updating: s.updating, } } func (s *Supervisor) setError(err error) { s.mu.Lock() s.lastError = err.Error() s.mu.Unlock() s.log.Printf("守护错误: %v", err) } func (s *Supervisor) delay() time.Duration { sec := s.cfg.RestartDelaySec if sec <= 0 { sec = 5 } return time.Duration(sec) * time.Second } // monitor 守护循环:启动 -> 等待退出 -> 期望运行时延时重启。 func (s *Supervisor) monitor() { for { s.mu.Lock() desired := s.desired s.mu.Unlock() if !desired { return } exe, args, err := s.resolveCommand() if err != nil { s.setError(err) s.sleepInterruptible(s.delay()) continue } cmd := exec.Command(exe, args...) hideWindow(cmd) stdout, errOut := cmd.StdoutPipe() stderr, errErr := cmd.StderrPipe() if errOut != nil || errErr != nil { s.setError(errors.New("无法创建输出管道")) s.sleepInterruptible(s.delay()) continue } if err := cmd.Start(); err != nil { s.setError(err) s.sleepInterruptible(s.delay()) continue } pid := cmd.Process.Pid s.mu.Lock() s.cmd = cmd s.pid = pid s.running = true s.startedAt = time.Now() s.lastError = "" s.webURL = "" s.mu.Unlock() s.log.Printf("dsh web 已启动 pid=%d: %s %s", pid, exe, strings.Join(args, " ")) go s.pipeLog("out", stdout) go s.pipeLog("err", stderr) waitErr := cmd.Wait() s.mu.Lock() s.running = false s.cmd = nil s.pid = 0 stillDesired := s.desired if stillDesired { s.restarts++ } s.mu.Unlock() s.log.Printf("dsh web 退出(err=%v),期望运行=%v", waitErr, stillDesired) if !stillDesired { return } sleep := s.delay() s.log.Printf("%v 后自动重启", sleep) s.sleepInterruptible(sleep) } } func (s *Supervisor) sleepInterruptible(d time.Duration) { deadline := time.Now().Add(d) for time.Now().Before(deadline) { s.mu.Lock() desired := s.desired s.mu.Unlock() if !desired { return } time.Sleep(200 * time.Millisecond) } } // pipeLog 把子进程输出写入日志文件。 func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) { if r == nil { return } sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { line := sc.Text() s.log.Printf("[dsh %s] %s", tag, line) // 捕获 dsh web 启动时输出的访问地址(含 token),供托盘直接打开 if idx := strings.Index(line, "http://"); idx >= 0 { url := strings.TrimSpace(line[idx:]) if i := strings.IndexAny(url, " \t"); i > 0 { url = url[:i] } if strings.HasPrefix(url, "http://") && len(url) > len("http://") { s.mu.Lock() if url != s.webURL { s.webURL = url s.log.Printf("捕获 Web 访问地址(含 token)") } s.mu.Unlock() } } } } // resolveCommand 解析启动命令:优先 node + dsh 入口 JS(无 shell 包装,最小化窗口风险)。 func (s *Supervisor) resolveCommand() (string, []string, error) { node := s.cfg.NodePath if node == "" { node = s.nodePath() } binJS := s.cfg.DshBinJS if binJS == "" { binJS = s.dshBinJS() } if node != "" && binJS != "" { args := []string{binJS, "web", "--no-open", "--host", s.cfg.WebHost, "--port", strconv.Itoa(s.cfg.WebPort)} return node, args, nil } if p, err := exec.LookPath("dsh.cmd"); err == nil { args := []string{"/c", p, "web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)} return "cmd.exe", args, nil } if p, err := exec.LookPath("dsh"); err == nil { args := []string{"web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)} return p, args, nil } return "", nil, errors.New("未找到 dsh 入口:请确认已全局安装 @deepseek-ai/dsh,或在配置中指定 nodePath / dshBinJs") } func (s *Supervisor) nodePath() string { s.mu.Lock() if s.nodeCache != "" { v := s.nodeCache s.mu.Unlock() return v } s.mu.Unlock() p, err := exec.LookPath("node") if err != nil { return "" } s.mu.Lock() s.nodeCache = p s.mu.Unlock() return p } // dshBinJS 探测 dsh 的 bin.js:APPDATA\npm 与 npm root -g。 func (s *Supervisor) dshBinJS() string { s.mu.Lock() if s.binCache != "" { v := s.binCache s.mu.Unlock() return v } s.mu.Unlock() var candidates []string if appdata := os.Getenv("APPDATA"); appdata != "" { candidates = append(candidates, filepath.Join(appdata, "npm", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js")) } if root := npmGlobalRoot(); root != "" { candidates = append(candidates, filepath.Join(root, "@deepseek-ai", "dsh", "lib", "bin.js")) } for _, c := range candidates { if fileExists(c) { s.mu.Lock() s.binCache = c s.mu.Unlock() s.log.Printf("dsh 入口: %s", c) return c } } return "" } // installedVersion 读取已安装 dsh 版本。 func (s *Supervisor) installedVersion() string { binJS := s.cfg.DshBinJS if binJS == "" { binJS = s.dshBinJS() } if binJS == "" { return "" } pkg := filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json") return readPackageVersion(pkg) } func fileExists(p string) bool { st, err := os.Stat(p) return err == nil && !st.IsDir() }