package aitools import ( "encoding/json" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) const npmPackageName = "@rlwj-lab/opsxdb-mcp" // MCPInstallInfo 设置 → MCP 页的安装/环境检测结果。 type MCPInstallInfo struct { Status string `json:"status"` // ready | not_installed | partial Installed bool `json:"installed"` CurrentVersion string `json:"currentVersion,omitempty"` LatestVersion string `json:"latestVersion,omitempty"` NodeVersion string `json:"nodeVersion,omitempty"` NpmAvailable bool `json:"npmAvailable"` NpmVersion string `json:"npmVersion,omitempty"` NpmPackageInstalled bool `json:"npmPackageInstalled"` NpmPackageVersion string `json:"npmPackageVersion,omitempty"` GoBinaryFound bool `json:"goBinaryFound"` GoBinaryPath string `json:"goBinaryPath,omitempty"` InstallCommand string `json:"installCommand"` NpxCommand string `json:"npxCommand"` PolicyPath string `json:"policyPath,omitempty"` Hint string `json:"hint,omitempty"` } // ProbeMCPInstall 检测 Node/npm、全局 npm 包、Go 二进制与 registry 最新版。 func ProbeMCPInstall() MCPInstallInfo { info := MCPInstallInfo{ Status: "not_installed", InstallCommand: "npm install -g " + npmPackageName + "@latest", NpxCommand: "npx -y " + npmPackageName, } if p, err := PolicyPath(); err == nil { info.PolicyPath = p } node, nodeVer := findToolVersion("node", "--version") npm, npmVer := findToolVersion("npm", "--version") info.NodeVersion = strings.TrimPrefix(strings.TrimSpace(nodeVer), "v") if node != "" && info.NodeVersion != "" { info.NodeVersion = "v" + strings.TrimPrefix(info.NodeVersion, "v") } info.NpmAvailable = npm != "" info.NpmVersion = strings.TrimSpace(npmVer) if bin := resolveGoMCPBinary(); bin != "" { info.GoBinaryFound = true info.GoBinaryPath = bin } if npm != "" { ver := npmGlobalPackageVersion(npm, npmPackageName) if ver != "" { info.NpmPackageInstalled = true info.NpmPackageVersion = ver info.CurrentVersion = ver } } if info.CurrentVersion == "" && info.GoBinaryFound { info.CurrentVersion = "native" } info.LatestVersion = fetchLatestNpmVersion(npmPackageName) switch { case info.GoBinaryFound && (info.NpmPackageInstalled || info.NpmAvailable): info.Status = "ready" info.Installed = true if !info.NpmPackageInstalled { info.Hint = "已找到 Go 二进制;建议再 npm i -g,或用 npx + OPSXDB_MCP_BIN。" } case info.GoBinaryFound: info.Status = "partial" info.Installed = true info.Hint = "已找到 Go 二进制,但未检测到 Node/npm。客户端可用绝对路径启动二进制。" case info.NpmPackageInstalled: info.Status = "partial" info.Installed = false info.Hint = "npm 包已装,但未找到 Go 二进制。请 go build 后设置 OPSXDB_MCP_BIN。" case info.NpmAvailable: info.Status = "not_installed" info.Hint = "检测到 Node/npm,尚未安装 MCP 包或 Go 二进制。" default: info.Status = "not_installed" info.Hint = "未检测到 Node.js。请安装 Node 18+,或直接用 Go 二进制绝对路径配置客户端。" } return info } func findToolVersion(name string, versionArg string) (bin, version string) { bin = lookPathAugmented(name) if bin == "" { return "", "" } out, err := exec.Command(bin, versionArg).CombinedOutput() if err != nil { return bin, "" } return bin, strings.TrimSpace(string(out)) } func lookPathAugmented(name string) string { if p, err := exec.LookPath(name); err == nil { return p } home, _ := os.UserHomeDir() var extras []string if home != "" { nvm := filepath.Join(home, ".nvm", "versions", "node") if ents, err := os.ReadDir(nvm); err == nil { for _, e := range ents { if e.IsDir() { extras = append(extras, filepath.Join(nvm, e.Name(), "bin")) } } } extras = append(extras, filepath.Join(home, ".local", "bin"), filepath.Join(home, "bin"), "/opt/homebrew/bin", "/usr/local/bin", ) } pathEnv := os.Getenv("PATH") aug := strings.Join(extras, string(os.PathListSeparator)) if pathEnv != "" { aug = aug + string(os.PathListSeparator) + pathEnv } cmd := exec.Command(name, "--version") cmd.Env = append(os.Environ(), "PATH="+aug) // Prefer LookPath with augmented PATH for _, dir := range extras { cand := filepath.Join(dir, name) if runtime.GOOS == "windows" { cand += ".exe" } if st, err := os.Stat(cand); err == nil && !st.IsDir() { return cand } } if p, err := exec.LookPath(name); err == nil { return p } _ = cmd return "" } func npmGlobalPackageVersion(npmBin, pkg string) string { out, err := exec.Command(npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput() if err != nil && len(out) == 0 { return "" } var raw struct { Dependencies map[string]struct { Version string `json:"version"` } `json:"dependencies"` } if json.Unmarshal(out, &raw) != nil { return "" } if d, ok := raw.Dependencies[pkg]; ok { return d.Version } return "" } func fetchLatestNpmVersion(pkg string) string { client := &http.Client{Timeout: 4 * time.Second} url := "https://registry.npmjs.org/" + pkg + "/latest" req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return "" } req.Header.Set("Accept", "application/json") res, err := client.Do(req) if err != nil { return "" } defer res.Body.Close() if res.StatusCode != 200 { return "" } var raw struct { Version string `json:"version"` } if json.NewDecoder(res.Body).Decode(&raw) != nil { return "" } return raw.Version } func resolveGoMCPBinary() string { if env := strings.TrimSpace(os.Getenv("OPSXDB_MCP_BIN")); env != "" { if isLikelyNativeBinary(env) { return env } } if p, err := exec.LookPath("opsxdb-mcp"); err == nil && isLikelyNativeBinary(p) { return p } home, _ := os.UserHomeDir() cands := []string{} if home != "" { cands = append(cands, filepath.Join(home, "devops", "opsxdb", "bin", "opsxdb-mcp"), filepath.Join(home, ".opsxdb", "bin", "opsxdb-mcp"), filepath.Join(home, ".local", "bin", "opsxdb-mcp"), ) } // Relative to running server binary (src-tauri/binaries or server/) if exe, err := os.Executable(); err == nil { dir := filepath.Dir(exe) cands = append(cands, filepath.Join(dir, "opsxdb-mcp"), filepath.Join(dir, "..", "opsxdb-mcp"), filepath.Join(dir, "..", "..", "bin", "opsxdb-mcp"), filepath.Join(dir, "..", "..", "..", "bin", "opsxdb-mcp"), ) } cands = append(cands, "/usr/local/bin/opsxdb-mcp", "/opt/homebrew/bin/opsxdb-mcp") for _, c := range cands { if isLikelyNativeBinary(c) { return c } } return "" } func isLikelyNativeBinary(path string) bool { st, err := os.Stat(path) if err != nil || st.IsDir() { return false } f, err := os.Open(path) if err != nil { return false } defer f.Close() buf := make([]byte, 8) n, _ := f.Read(buf) if n < 2 { return false } // shebang → script if buf[0] == '#' && buf[1] == '!' { return false } if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' { return true } if buf[0] == 'M' && buf[1] == 'Z' { return true } if n >= 4 { be := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3]) le := uint32(buf[3])<<24 | uint32(buf[2])<<16 | uint32(buf[1])<<8 | uint32(buf[0]) macho := map[uint32]bool{ 0xfeedfacf: true, 0xcffaedfe: true, 0xfeedface: true, 0xcefaedfe: true, 0xcafebabe: true, 0xbebafeca: true, 0xcafebabf: true, 0xbfbafeca: true, } if macho[be] || macho[le] { return true } } return false }