Prefer schema --compact in agent docs, remove服务发现/cache teaching, and stop doctor from reporting a no-op cache health item. Co-authored-by: Cursor <cursoragent@cursor.com>
437 lines
12 KiB
Go
437 lines
12 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
doctorKeychainDiagnose = keychain.Diagnose
|
|
doctorAuthStatus = (*authpkg.OAuthProvider).Status
|
|
doctorAuthAccessToken = (*authpkg.OAuthProvider).GetAccessToken
|
|
doctorHTTPDo = (*http.Client).Do
|
|
doctorFetchLatestRelease = func() (*upgrade.ReleaseInfo, error) { return upgrade.NewClient().FetchLatestRelease() }
|
|
doctorNeedsUpgrade = upgrade.NeedsUpgrade
|
|
)
|
|
|
|
// checkStatus represents the outcome of a single doctor check.
|
|
type checkStatus string
|
|
|
|
const (
|
|
statusPass checkStatus = "pass"
|
|
statusWarn checkStatus = "warn"
|
|
statusFail checkStatus = "fail"
|
|
)
|
|
|
|
// checkResult holds the outcome of a single doctor check.
|
|
type checkResult struct {
|
|
Name string `json:"name"`
|
|
Status checkStatus `json:"status"`
|
|
Message string `json:"message"`
|
|
Hint string `json:"hint,omitempty"`
|
|
Detail any `json:"detail,omitempty"`
|
|
}
|
|
|
|
func newDoctorCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "doctor",
|
|
Short: "环境健康检查",
|
|
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
|
Args: cobra.NoArgs,
|
|
DisableAutoGenTag: true,
|
|
RunE: runDoctor,
|
|
}
|
|
cmd.Flags().Bool("json", false, "以 JSON 格式输出")
|
|
cmd.Flags().Int("timeout", 10, "网络检查超时时间 (秒)")
|
|
cmd.Flags().Bool("perf", false, "额外展示最近一次性能报告")
|
|
return cmd
|
|
}
|
|
|
|
func runDoctor(cmd *cobra.Command, _ []string) error {
|
|
jsonOut, _ := cmd.Flags().GetBool("json")
|
|
timeout, _ := cmd.Flags().GetInt("timeout")
|
|
if timeout <= 0 {
|
|
timeout = 10
|
|
}
|
|
networkTimeout := time.Duration(timeout) * time.Second
|
|
|
|
w := cmd.OutOrStdout()
|
|
checks := make([]checkResult, 0, 4)
|
|
|
|
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
|
|
checks = append(checks, authResult)
|
|
|
|
keychainResult := doctorCheckKeychain(w, jsonOut)
|
|
checks = append(checks, keychainResult)
|
|
|
|
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
|
checks = append(checks, networkResult)
|
|
|
|
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
|
checks = append(checks, versionResult)
|
|
|
|
showPerf, _ := cmd.Flags().GetBool("perf")
|
|
if showPerf {
|
|
perfResult := doctorCheckPerf(w, jsonOut)
|
|
checks = append(checks, perfResult)
|
|
}
|
|
|
|
pass, warn, fail := countResults(checks)
|
|
|
|
if jsonOut {
|
|
result := map[string]any{
|
|
"kind": "doctor",
|
|
"checks": checks,
|
|
"summary": map[string]int{
|
|
"pass": pass,
|
|
"warn": warn,
|
|
"fail": fail,
|
|
},
|
|
}
|
|
if showPerf {
|
|
if report, err := LoadLatestReport(); err == nil {
|
|
result["perf_report"] = report
|
|
}
|
|
}
|
|
return output.WriteJSON(w, result)
|
|
}
|
|
|
|
fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail)))
|
|
fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail)
|
|
if fail > 0 {
|
|
return fmt.Errorf("诊断发现 %d 项失败", fail)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Auth check ──────────────────────────────────────────────────────────
|
|
|
|
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
|
|
if !jsonOut {
|
|
fmt.Fprint(w, tui.Dim("检查登录状态... "))
|
|
}
|
|
|
|
configDir := defaultConfigDir()
|
|
provider := authpkg.NewOAuthProvider(configDir, nil)
|
|
configureOAuthProviderCompatibility(provider, configDir)
|
|
|
|
data, err := doctorAuthStatus(provider)
|
|
if err != nil || data == nil {
|
|
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
|
r := checkResult{
|
|
Name: "auth",
|
|
Status: statusFail,
|
|
Message: diagnostic.Message,
|
|
Hint: diagnostic.Hint,
|
|
Detail: map[string]string{"reason": diagnostic.Reason},
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
|
|
if !edition.Get().IsEmbedded {
|
|
r.Hint = "运行 dws auth login 进行登录"
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
|
|
if !data.IsAccessTokenValid() {
|
|
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
_, refreshErr := doctorAuthAccessToken(provider, refreshCtx)
|
|
cancel()
|
|
if refreshErr != nil {
|
|
r := checkResult{
|
|
Name: "auth",
|
|
Status: statusWarn,
|
|
Message: "Refresh Token 有效, 但自动刷新 Access Token 失败",
|
|
Hint: "运行 dws auth login 重新登录",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
}
|
|
|
|
r := checkResult{
|
|
Name: "auth",
|
|
Status: statusPass,
|
|
Message: "已登录",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
r := checkResult{Name: "auth", Status: statusFail, Message: "登录已过期"}
|
|
if !edition.Get().IsEmbedded {
|
|
r.Hint = "运行 dws auth login 重新登录"
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ── Keychain check ─────────────────────────────────────────────────────
|
|
|
|
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
|
|
if !jsonOut {
|
|
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
|
|
}
|
|
|
|
diagnostic := doctorKeychainDiagnose()
|
|
r := checkResult{
|
|
Name: "keychain",
|
|
Status: statusPass,
|
|
Message: diagnostic.Message,
|
|
Detail: diagnostic.Detail,
|
|
}
|
|
if !diagnostic.OK {
|
|
r.Status = statusFail
|
|
r.Hint = diagnostic.Hint
|
|
if diagnostic.Detail == nil {
|
|
r.Detail = map[string]string{"reason": diagnostic.Reason}
|
|
} else if diagnostic.Reason != "" {
|
|
detail := make(map[string]string, len(diagnostic.Detail)+1)
|
|
for k, v := range diagnostic.Detail {
|
|
detail[k] = v
|
|
}
|
|
detail["reason"] = diagnostic.Reason
|
|
r.Detail = detail
|
|
}
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ── Network check ───────────────────────────────────────────────────────
|
|
|
|
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
|
if !jsonOut {
|
|
fmt.Fprint(w, tui.Dim("检查网络连通性... "))
|
|
}
|
|
|
|
baseURL := config.GetMCPBaseURL()
|
|
httpClient := &http.Client{Timeout: timeout}
|
|
|
|
start := time.Now()
|
|
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL, nil)
|
|
if err != nil {
|
|
r := checkResult{
|
|
Name: "network",
|
|
Status: statusFail,
|
|
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
|
|
Hint: "请检查网络连接或代理设置",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
resp, err := doctorHTTPDo(httpClient, req)
|
|
latency := time.Since(start)
|
|
if err != nil {
|
|
r := checkResult{
|
|
Name: "network",
|
|
Status: statusFail,
|
|
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
|
|
Hint: "请检查网络连接或代理设置",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
resp.Body.Close()
|
|
|
|
r := checkResult{
|
|
Name: "network",
|
|
Status: statusPass,
|
|
Message: fmt.Sprintf("%s 可达 (延迟 %dms)", baseURL, latency.Milliseconds()),
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ── Version check ───────────────────────────────────────────────────────
|
|
|
|
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
|
if !jsonOut {
|
|
fmt.Fprint(w, tui.Dim("检查版本更新... "))
|
|
}
|
|
|
|
currentVer := version
|
|
|
|
latest, err := doctorFetchLatestRelease()
|
|
if err != nil {
|
|
r := checkResult{
|
|
Name: "version",
|
|
Status: statusFail,
|
|
Message: fmt.Sprintf("无法获取最新版本: %v", err),
|
|
Hint: "请检查网络连接",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
if doctorNeedsUpgrade(currentVer, latest.Version) {
|
|
r := checkResult{
|
|
Name: "version",
|
|
Status: statusWarn,
|
|
Message: fmt.Sprintf("有新版本 (当前 %s, 最新 v%s)", ensureV(currentVer), latest.Version),
|
|
Hint: "运行 dws upgrade 升级到最新版本",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
r := checkResult{
|
|
Name: "version",
|
|
Status: statusPass,
|
|
Message: fmt.Sprintf("已是最新版本 %s", ensureV(currentVer)),
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ── Output helpers ──────────────────────────────────────────────────────
|
|
|
|
func printCheckResult(w io.Writer, r checkResult) {
|
|
icon := statusIcon(r.Status)
|
|
message := r.Message
|
|
switch r.Status {
|
|
case statusPass:
|
|
message = tui.Success(message)
|
|
case statusWarn:
|
|
message = tui.Warning(message)
|
|
case statusFail:
|
|
message = tui.Danger(message)
|
|
}
|
|
fmt.Fprintf(w, "%s %s\n", icon, message)
|
|
if r.Hint != "" {
|
|
fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint))
|
|
}
|
|
}
|
|
|
|
func statusIcon(s checkStatus) string {
|
|
switch s {
|
|
case statusPass:
|
|
return "✅"
|
|
case statusWarn:
|
|
return "⚠️"
|
|
case statusFail:
|
|
return "❌"
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
func countResults(checks []checkResult) (pass, warn, fail int) {
|
|
for _, c := range checks {
|
|
switch c.Status {
|
|
case statusPass:
|
|
pass++
|
|
case statusWarn:
|
|
warn++
|
|
case statusFail:
|
|
fail++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// ── Perf report check ──────────────────────────────────────────────────
|
|
|
|
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
|
|
if !jsonOut {
|
|
fmt.Fprint(w, tui.Dim("检查性能报告... "))
|
|
}
|
|
|
|
report, err := LoadLatestReport()
|
|
if err != nil {
|
|
r := checkResult{
|
|
Name: "perf",
|
|
Status: statusWarn,
|
|
Message: "未找到性能报告",
|
|
Hint: "设置 DWS_PERF_REPORT=auto 后运行任意命令生成报告",
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
r := checkResult{
|
|
Name: "perf",
|
|
Status: statusPass,
|
|
Message: fmt.Sprintf("报告可用 (%s, %s)", report.Command, report.Timestamp.Local().Format("2006-01-02 15:04")),
|
|
}
|
|
if !jsonOut {
|
|
printCheckResult(w, r)
|
|
printPerfReportSummary(w, report)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func printPerfReportSummary(w io.Writer, report *PerfReport) {
|
|
fmt.Fprintf(w, "\n最近一次性能报告 (%s, %s):\n",
|
|
report.Command, report.Timestamp.Local().Format("2006-01-02 15:04"))
|
|
|
|
for _, p := range report.Phases {
|
|
marker := ""
|
|
if p.Name == report.Slowest {
|
|
marker = " ← 最慢"
|
|
}
|
|
fmt.Fprintf(w, " %-25s %dms%s\n", p.Name, p.DurationMs, marker)
|
|
}
|
|
fmt.Fprintf(w, " %-25s ─────────\n", "─────────────────────────")
|
|
fmt.Fprintf(w, " %-25s %dms (框架开销 %dms)\n", "总耗时", report.TotalMs, report.OverheadMs)
|
|
}
|