Skip to content

Commit 3cc1064

Browse files
authored
feat(cmd): add doctor command for diagnosing configuration issues (#261)
1 parent f1dfab5 commit 3cc1064

29 files changed

Lines changed: 4281 additions & 10 deletions

src/cmd/doctor.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/doctor"
10+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/ui"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var (
15+
doctorFix bool
16+
doctorYes bool
17+
doctorNoFix bool
18+
)
19+
20+
var doctorCmd = &cobra.Command{
21+
Use: "doctor",
22+
Short: "Diagnose dtvem configuration issues",
23+
Long: `Run a battery of checks against your dtvem installation and surface any
24+
configuration problems it finds.
25+
26+
By default, doctor is read-only: it prints a report and exits non-zero
27+
when an error-severity finding is present, but it does not modify any
28+
state. Pass --fix to enable interactive remediation; pair with --yes
29+
to apply every fixable finding without prompting.
30+
31+
Findings are grouped by severity. Each one is marked either [fixable]
32+
(doctor knows how to remediate it) or [manual] (you'll see step-by-step
33+
instructions). Manual findings are never auto-applied, even with --fix.
34+
35+
Examples:
36+
dtvem doctor # Report problems, don't change anything
37+
dtvem doctor --fix # Prompt to fix each fixable finding
38+
dtvem doctor --fix --yes # Apply every fixable finding non-interactively
39+
dtvem doctor --no-fix # Explicit read-only mode (for scripts)`,
40+
Run: func(cmd *cobra.Command, args []string) {
41+
if doctorFix && doctorNoFix {
42+
ui.Error("--fix and --no-fix are mutually exclusive")
43+
os.Exit(2)
44+
}
45+
46+
result := doctor.RunAll()
47+
renderReport(result)
48+
49+
// Apply fixes if requested. We run this even when there are no
50+
// error-severity findings — warnings can also be fixable, and
51+
// nothing prevents the user from cleaning those up too.
52+
if doctorFix {
53+
applyFixes(result, doctorYes)
54+
}
55+
56+
if result.HasErrors() {
57+
// Non-zero exit so CI / wrapping scripts can react. We use
58+
// os.Exit rather than returning an error from Run so we
59+
// don't trigger Cobra's "Error:" prefix on the rendered
60+
// report.
61+
os.Exit(1)
62+
}
63+
},
64+
}
65+
66+
// renderReport prints findings grouped into a passing section and a
67+
// problems section. We deliberately keep the layout close to the
68+
// example in the GitHub issue so the report is greppable and the user
69+
// can find specific finding shapes by eye.
70+
func renderReport(r doctor.Result) {
71+
ui.Header("dtvem doctor")
72+
fmt.Println()
73+
74+
var ok, problems []doctor.CheckResult
75+
for _, cr := range r.Results {
76+
if cr.Finding.OK {
77+
ok = append(ok, cr)
78+
} else {
79+
problems = append(problems, cr)
80+
}
81+
}
82+
83+
for _, cr := range problems {
84+
printFinding(cr.Finding)
85+
}
86+
87+
for _, cr := range ok {
88+
ui.Success("%s", cr.Finding.Title)
89+
}
90+
91+
fmt.Println()
92+
summarize(len(ok), len(problems), r.HasErrors())
93+
}
94+
95+
// printFinding renders a single non-OK finding: a severity-colored
96+
// title line with [fixable] or [manual] tag, the aligned details
97+
// block, and the resolution text.
98+
func printFinding(f doctor.Finding) {
99+
tag := "[manual]"
100+
if f.Fixable() {
101+
tag = "[fixable]"
102+
}
103+
104+
header := fmt.Sprintf("%s %s", f.Title, tag)
105+
switch f.Severity {
106+
case doctor.SeverityError:
107+
ui.Error("%s", header)
108+
case doctor.SeverityWarning:
109+
ui.Warning("%s", header)
110+
default:
111+
ui.Info("%s", header)
112+
}
113+
114+
// Align the keys so the values form a tidy column. The longest key
115+
// drives column width; we don't pad past it.
116+
maxKey := 0
117+
for _, d := range f.Details {
118+
if l := len(d.Key); l > maxKey {
119+
maxKey = l
120+
}
121+
}
122+
for _, d := range f.Details {
123+
pad := strings.Repeat(" ", maxKey-len(d.Key))
124+
fmt.Printf(" %s:%s %s\n", d.Key, pad, d.Value)
125+
}
126+
127+
if f.Resolution != "" {
128+
// Indent every line of the resolution so multi-line manual
129+
// instructions stay visually grouped under the finding.
130+
for _, line := range strings.Split(f.Resolution, "\n") {
131+
fmt.Printf(" %s\n", line)
132+
}
133+
}
134+
fmt.Println()
135+
}
136+
137+
// summarize prints the closing one-liner so users (and CI logs) see a
138+
// quick result without having to count findings themselves.
139+
func summarize(ok, problems int, hasErrors bool) {
140+
if problems == 0 {
141+
ui.Success("All %d check(s) passed", ok)
142+
return
143+
}
144+
if hasErrors {
145+
ui.Error("%d problem(s) found across %d check(s)", problems, ok+problems)
146+
} else {
147+
ui.Warning("%d non-error problem(s) found across %d check(s)", problems, ok+problems)
148+
}
149+
}
150+
151+
// applyFixes walks the fixable findings and applies each one — either
152+
// after a y/N prompt, or immediately when --yes was passed. We print a
153+
// running tally so users see what doctor did, in the order it did it.
154+
func applyFixes(r doctor.Result, yes bool) {
155+
fixable := r.Fixable()
156+
if len(fixable) == 0 {
157+
ui.Info("No fixable findings to apply.")
158+
return
159+
}
160+
161+
fmt.Println()
162+
ui.Header("Applying fixes")
163+
fmt.Println()
164+
165+
reader := bufio.NewReader(os.Stdin)
166+
for _, cr := range fixable {
167+
if !yes {
168+
fmt.Printf("Fix: %s? [y/N] ", cr.Finding.Title)
169+
line, _ := reader.ReadString('\n')
170+
line = strings.ToLower(strings.TrimSpace(line))
171+
if line != "y" && line != "yes" {
172+
ui.Info("Skipped: %s", cr.Finding.Title)
173+
continue
174+
}
175+
}
176+
177+
if err := cr.Finding.Fix(); err != nil {
178+
ui.Error("Fix failed for %s: %v", cr.Finding.Title, err)
179+
continue
180+
}
181+
ui.Success("Fixed: %s", cr.Finding.Title)
182+
}
183+
}
184+
185+
func init() {
186+
doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Interactively apply fixes for fixable findings")
187+
doctorCmd.Flags().BoolVarP(&doctorYes, "yes", "y", false, "Skip prompts when --fix is set; apply all fixable findings")
188+
doctorCmd.Flags().BoolVar(&doctorNoFix, "no-fix", false, "Explicit read-only mode (for scripts)")
189+
rootCmd.AddCommand(doctorCmd)
190+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package doctor
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"sort"
7+
"strings"
8+
9+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
10+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime"
11+
)
12+
13+
// configuredRuntimesCheck verifies that every runtime/version pair in
14+
// the global ~/.dtvem/config/runtimes.json points at an installed
15+
// version. A mismatch means `dtvem current` will report a version
16+
// dtvem can't actually execute, and shim invocations for that runtime
17+
// will fail with a confusing "version not installed" error rather
18+
// than a useful one at config-load time.
19+
//
20+
// The fix isn't automatable in general — doctor can't tell whether
21+
// the user wants to install the configured version or edit the
22+
// config to match what's installed — so this is a manual check with
23+
// a one-line `dtvem install` suggestion per mismatch.
24+
type configuredRuntimesCheck struct {
25+
configPath func() string
26+
readConfig func(path string) (config.RuntimesConfig, error)
27+
getProvider func(name string) (runtime.ShimProvider, error)
28+
}
29+
30+
func newConfiguredRuntimesCheck() *configuredRuntimesCheck {
31+
return &configuredRuntimesCheck{
32+
configPath: config.GlobalConfigPath,
33+
readConfig: config.ReadAllRuntimes,
34+
getProvider: runtime.GetShimProvider,
35+
}
36+
}
37+
38+
func (configuredRuntimesCheck) Name() string { return "configured-runtimes-installed" }
39+
40+
func (c configuredRuntimesCheck) Run() Finding {
41+
cfgPath := c.configPath()
42+
cfg, err := c.readConfig(cfgPath)
43+
if err != nil {
44+
if os.IsNotExist(err) {
45+
return Finding{OK: true, Title: "No global runtimes config to check"}
46+
}
47+
return Finding{
48+
Severity: SeverityWarning,
49+
Title: "Could not read global runtimes config",
50+
Details: []Detail{{Key: "Path", Value: cfgPath}, {Key: "Error", Value: err.Error()}},
51+
Resolution: "Check that " + cfgPath + " is valid JSON and readable.",
52+
}
53+
}
54+
if len(cfg) == 0 {
55+
return Finding{OK: true, Title: "Global runtimes config is empty"}
56+
}
57+
58+
type problem struct {
59+
runtimeName string
60+
version string
61+
displayName string
62+
detail string
63+
}
64+
var problems []problem
65+
66+
for name, version := range cfg {
67+
p, err := c.getProvider(name)
68+
if err != nil {
69+
problems = append(problems, problem{
70+
runtimeName: name,
71+
version: version,
72+
displayName: name,
73+
detail: "configured runtime is unknown to dtvem (no provider registered)",
74+
})
75+
continue
76+
}
77+
78+
installed, err := p.IsInstalled(version)
79+
if err != nil {
80+
problems = append(problems, problem{
81+
runtimeName: name,
82+
version: version,
83+
displayName: p.DisplayName(),
84+
detail: fmt.Sprintf("could not check install status: %v", err),
85+
})
86+
continue
87+
}
88+
if !installed {
89+
problems = append(problems, problem{
90+
runtimeName: name,
91+
version: version,
92+
displayName: p.DisplayName(),
93+
detail: fmt.Sprintf("version %s is not installed (run `dtvem install %s %s`)", version, name, version),
94+
})
95+
}
96+
}
97+
98+
if len(problems) == 0 {
99+
return Finding{OK: true, Title: "All configured runtimes are installed"}
100+
}
101+
102+
// Stable order so the report doesn't shuffle between runs.
103+
sort.Slice(problems, func(i, j int) bool {
104+
if problems[i].runtimeName == problems[j].runtimeName {
105+
return problems[i].version < problems[j].version
106+
}
107+
return problems[i].runtimeName < problems[j].runtimeName
108+
})
109+
110+
details := make([]Detail, 0, len(problems)+1)
111+
details = append(details, Detail{Key: "Config", Value: cfgPath})
112+
for _, p := range problems {
113+
details = append(details, Detail{Key: p.displayName, Value: p.detail})
114+
}
115+
116+
return Finding{
117+
Severity: SeverityError,
118+
Title: fmt.Sprintf("%d configured runtime version%s not installed", len(problems), plural(len(problems), "", "s")),
119+
Details: details,
120+
Resolution: configuredRuntimesResolution(problems[0].runtimeName, problems[0].version),
121+
}
122+
}
123+
124+
// configuredRuntimesResolution suggests the install command for the
125+
// first problem so the user has a concrete next step. Listing every
126+
// install command in the resolution would duplicate the detail block
127+
// without adding info.
128+
func configuredRuntimesResolution(runtimeName, version string) string {
129+
return strings.Join([]string{
130+
"Install the missing version(s) listed above, or edit",
131+
" " + config.GlobalConfigPath(),
132+
"to reference versions that are installed.",
133+
"",
134+
fmt.Sprintf("Example: dtvem install %s %s", runtimeName, version),
135+
}, "\n")
136+
}
137+
138+
func init() {
139+
Register(newConfiguredRuntimesCheck())
140+
}

0 commit comments

Comments
 (0)