|
| 1 | +// Package doctor implements the diagnostic check runner shared by the |
| 2 | +// `ctm doctor` CLI and the `GET /api/doctor` HTTP endpoint. |
| 3 | +// |
| 4 | +// The CLI formats [ok/warn/err] Check slices as coloured lines; the |
| 5 | +// HTTP endpoint JSON-encodes the same slice. Adding a new check means |
| 6 | +// appending one function here — both surfaces pick it up without edits. |
| 7 | +package doctor |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "fmt" |
| 12 | + "os" |
| 13 | + "os/exec" |
| 14 | + "strings" |
| 15 | + |
| 16 | + "github.com/RandomCodeSpace/ctm/internal/config" |
| 17 | + "github.com/RandomCodeSpace/ctm/internal/serve/auth" |
| 18 | + "github.com/RandomCodeSpace/ctm/internal/session" |
| 19 | + "github.com/RandomCodeSpace/ctm/internal/tmux" |
| 20 | +) |
| 21 | + |
| 22 | +// Status enumerates the three check outcomes surfaced to both CLI and UI. |
| 23 | +// Values are the wire form — do not rename without bumping the API. |
| 24 | +const ( |
| 25 | + StatusOK = "ok" |
| 26 | + StatusWarn = "warn" |
| 27 | + StatusErr = "err" |
| 28 | +) |
| 29 | + |
| 30 | +// Check is the single result row. JSON tags are the /api/doctor wire |
| 31 | +// contract; Remediation is optional and omitted when empty. |
| 32 | +type Check struct { |
| 33 | + Name string `json:"name"` |
| 34 | + Status string `json:"status"` |
| 35 | + Message string `json:"message,omitempty"` |
| 36 | + Remediation string `json:"remediation,omitempty"` |
| 37 | +} |
| 38 | + |
| 39 | +// LookupBinary resolves a named binary on PATH. Exposed so the CLI |
| 40 | +// formatter (cmd/doctor.go) can share the exact same probe logic |
| 41 | +// rather than shelling out a second time. |
| 42 | +func LookupBinary(name string) (path string, ok bool) { |
| 43 | + p, err := exec.LookPath(name) |
| 44 | + if err != nil { |
| 45 | + return "", false |
| 46 | + } |
| 47 | + return p, true |
| 48 | +} |
| 49 | + |
| 50 | +// TmuxVersion runs `tmux -V` under ctx and returns the trimmed output. |
| 51 | +// ok=false means tmux is either missing or refused to print a version. |
| 52 | +func TmuxVersion(ctx context.Context) (version string, ok bool) { |
| 53 | + if _, err := exec.LookPath("tmux"); err != nil { |
| 54 | + return "", false |
| 55 | + } |
| 56 | + b, err := exec.CommandContext(ctx, "tmux", "-V").Output() |
| 57 | + if err != nil { |
| 58 | + return "", false |
| 59 | + } |
| 60 | + return strings.TrimSpace(string(b)), true |
| 61 | +} |
| 62 | + |
| 63 | +// Run executes every diagnostic check and returns the results in |
| 64 | +// display order. All checks respect ctx cancellation; Run returns |
| 65 | +// immediately with whatever it has accumulated if ctx expires. |
| 66 | +// |
| 67 | +// cfg is the already-loaded user config. A zero-valued Config is |
| 68 | +// legal: checks that depend on user-configurable lists (required_env, |
| 69 | +// required_in_path) simply report "not configured". |
| 70 | +func Run(ctx context.Context, cfg config.Config) []Check { |
| 71 | + checks := make([]Check, 0, 16) |
| 72 | + |
| 73 | + // Each checker is free to shell out; we gate the slow ones on |
| 74 | + // ctx.Err() so a caller with a 5-s deadline can still return a |
| 75 | + // partial list rather than blocking the HTTP response. |
| 76 | + for _, fn := range []func(context.Context, config.Config) []Check{ |
| 77 | + checkDependencies, |
| 78 | + checkTmuxVersion, |
| 79 | + checkRequiredEnv, |
| 80 | + checkRequiredInPath, |
| 81 | + checkServeToken, |
| 82 | + checkConfig, |
| 83 | + checkSessions, |
| 84 | + } { |
| 85 | + if ctx.Err() != nil { |
| 86 | + break |
| 87 | + } |
| 88 | + checks = append(checks, fn(ctx, cfg)...) |
| 89 | + } |
| 90 | + return checks |
| 91 | +} |
| 92 | + |
| 93 | +func checkDependencies(_ context.Context, _ config.Config) []Check { |
| 94 | + deps := []string{"tmux", "claude", "git"} |
| 95 | + out := make([]Check, 0, len(deps)) |
| 96 | + for _, dep := range deps { |
| 97 | + c := Check{Name: "dep:" + dep} |
| 98 | + if path, err := exec.LookPath(dep); err == nil { |
| 99 | + c.Status = StatusOK |
| 100 | + c.Message = path |
| 101 | + } else { |
| 102 | + c.Status = StatusWarn |
| 103 | + c.Message = fmt.Sprintf("%s not found in PATH", dep) |
| 104 | + c.Remediation = fmt.Sprintf( |
| 105 | + "install %s and ensure it is on PATH (e.g. `apt install %s` on Debian)", |
| 106 | + dep, dep) |
| 107 | + } |
| 108 | + out = append(out, c) |
| 109 | + } |
| 110 | + return out |
| 111 | +} |
| 112 | + |
| 113 | +func checkTmuxVersion(ctx context.Context, _ config.Config) []Check { |
| 114 | + c := Check{Name: "tmux:version"} |
| 115 | + // Fail fast if tmux isn't even on PATH — the dep check already |
| 116 | + // reported the warning, no need to double-log. |
| 117 | + if _, err := exec.LookPath("tmux"); err != nil { |
| 118 | + c.Status = StatusWarn |
| 119 | + c.Message = "tmux not installed" |
| 120 | + c.Remediation = "install tmux first (see dep:tmux)" |
| 121 | + return []Check{c} |
| 122 | + } |
| 123 | + cmd := exec.CommandContext(ctx, "tmux", "-V") |
| 124 | + b, err := cmd.Output() |
| 125 | + if err != nil { |
| 126 | + c.Status = StatusWarn |
| 127 | + c.Message = fmt.Sprintf("could not read tmux -V: %v", err) |
| 128 | + c.Remediation = "verify tmux works: `tmux -V` in a shell" |
| 129 | + return []Check{c} |
| 130 | + } |
| 131 | + c.Status = StatusOK |
| 132 | + c.Message = strings.TrimSpace(string(b)) |
| 133 | + return []Check{c} |
| 134 | +} |
| 135 | + |
| 136 | +func checkRequiredEnv(_ context.Context, cfg config.Config) []Check { |
| 137 | + if len(cfg.RequiredEnv) == 0 { |
| 138 | + return nil |
| 139 | + } |
| 140 | + out := make([]Check, 0, len(cfg.RequiredEnv)) |
| 141 | + for _, name := range cfg.RequiredEnv { |
| 142 | + c := Check{Name: "env:" + name} |
| 143 | + if v, ok := os.LookupEnv(name); ok && v != "" { |
| 144 | + c.Status = StatusOK |
| 145 | + c.Message = "set" |
| 146 | + } else { |
| 147 | + c.Status = StatusErr |
| 148 | + c.Message = fmt.Sprintf("%s is not set", name) |
| 149 | + c.Remediation = fmt.Sprintf("export %s=... in your shell rc or the session's env file", name) |
| 150 | + } |
| 151 | + out = append(out, c) |
| 152 | + } |
| 153 | + return out |
| 154 | +} |
| 155 | + |
| 156 | +func checkRequiredInPath(_ context.Context, cfg config.Config) []Check { |
| 157 | + if len(cfg.RequiredInPath) == 0 { |
| 158 | + return nil |
| 159 | + } |
| 160 | + out := make([]Check, 0, len(cfg.RequiredInPath)) |
| 161 | + for _, bin := range cfg.RequiredInPath { |
| 162 | + c := Check{Name: "path:" + bin} |
| 163 | + if path, err := exec.LookPath(bin); err == nil { |
| 164 | + c.Status = StatusOK |
| 165 | + c.Message = path |
| 166 | + } else { |
| 167 | + c.Status = StatusWarn |
| 168 | + c.Message = fmt.Sprintf("%s not found on PATH", bin) |
| 169 | + c.Remediation = fmt.Sprintf("install %s or add its directory to PATH", bin) |
| 170 | + } |
| 171 | + out = append(out, c) |
| 172 | + } |
| 173 | + return out |
| 174 | +} |
| 175 | + |
| 176 | +func checkServeToken(_ context.Context, _ config.Config) []Check { |
| 177 | + c := Check{Name: "serve:token"} |
| 178 | + tokenPath := auth.TokenPath() |
| 179 | + info, err := os.Stat(tokenPath) |
| 180 | + if err != nil { |
| 181 | + c.Status = StatusErr |
| 182 | + c.Message = fmt.Sprintf("missing at %s", tokenPath) |
| 183 | + c.Remediation = "run `ctm doctor` once (on CLI) to seed serve.token, or `ctm install`" |
| 184 | + return []Check{c} |
| 185 | + } |
| 186 | + mode := info.Mode().Perm() |
| 187 | + if mode != 0o600 { |
| 188 | + c.Status = StatusWarn |
| 189 | + c.Message = fmt.Sprintf("present but mode is %o (want 0600)", mode) |
| 190 | + c.Remediation = fmt.Sprintf("run: chmod 600 %s", tokenPath) |
| 191 | + return []Check{c} |
| 192 | + } |
| 193 | + c.Status = StatusOK |
| 194 | + c.Message = fmt.Sprintf("present (mode 0600, %d bytes)", info.Size()) |
| 195 | + return []Check{c} |
| 196 | +} |
| 197 | + |
| 198 | +func checkConfig(_ context.Context, cfg config.Config) []Check { |
| 199 | + c := Check{Name: "config:load"} |
| 200 | + cfgPath := config.ConfigPath() |
| 201 | + if _, err := os.Stat(cfgPath); err != nil { |
| 202 | + c.Status = StatusWarn |
| 203 | + c.Message = fmt.Sprintf("%s not present", cfgPath) |
| 204 | + c.Remediation = "run any `ctm` command once to seed defaults" |
| 205 | + return []Check{c} |
| 206 | + } |
| 207 | + // cfg is passed in already loaded; we check that it has *something* |
| 208 | + // populated to distinguish "zero value" (caller forgot to load) |
| 209 | + // from "file exists and parsed". |
| 210 | + if cfg.DefaultMode == "" && len(cfg.RequiredEnv) == 0 && cfg.ScrollbackLines == 0 { |
| 211 | + c.Status = StatusWarn |
| 212 | + c.Message = fmt.Sprintf("%s is present but appears empty", cfgPath) |
| 213 | + c.Remediation = "delete the file and re-run any `ctm` command to re-seed defaults" |
| 214 | + return []Check{c} |
| 215 | + } |
| 216 | + c.Status = StatusOK |
| 217 | + c.Message = fmt.Sprintf("loaded (default_mode=%s, scrollback=%d)", |
| 218 | + cfg.DefaultMode, cfg.ScrollbackLines) |
| 219 | + return []Check{c} |
| 220 | +} |
| 221 | + |
| 222 | +func checkSessions(_ context.Context, _ config.Config) []Check { |
| 223 | + c := Check{Name: "sessions:store"} |
| 224 | + path := config.SessionsPath() |
| 225 | + store := session.NewStore(path) |
| 226 | + sessions, err := store.List() |
| 227 | + if err != nil { |
| 228 | + c.Status = StatusWarn |
| 229 | + c.Message = fmt.Sprintf("could not list sessions: %v", err) |
| 230 | + c.Remediation = fmt.Sprintf("inspect %s; if corrupt, delete it (sessions will be rebuilt from tmux on next attach)", path) |
| 231 | + return []Check{c} |
| 232 | + } |
| 233 | + // Count tmux liveness so the panel tells users whether the store |
| 234 | + // agrees with reality without them opening another panel. |
| 235 | + tc := tmux.NewClient(config.TmuxConfPath()) |
| 236 | + alive := 0 |
| 237 | + for _, s := range sessions { |
| 238 | + if tc.HasSession(s.Name) { |
| 239 | + alive++ |
| 240 | + } |
| 241 | + } |
| 242 | + c.Status = StatusOK |
| 243 | + if len(sessions) == 0 { |
| 244 | + c.Message = "no sessions on record" |
| 245 | + } else { |
| 246 | + c.Message = fmt.Sprintf("%d session(s), %d tmux-alive", len(sessions), alive) |
| 247 | + } |
| 248 | + return []Check{c} |
| 249 | +} |
0 commit comments