Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,15 @@ tasks:
- task: tidy
- task: lint
- task: test

validate:
desc: Runs config validate command
cmds:
- go run *.go config validate

validate:invalid:
desc: Runs config validate with an intentionally invalid config to test error output
env:
HIVE_CONFIG: "./config.invalid.yaml"
cmds:
- go run *.go config validate || true
18 changes: 18 additions & 0 deletions config.invalid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Invalid config file for testing validation output
# This file passes basic validation but fails deep validation

commands:
spawn:
- "echo {{.Path}" # Invalid template - unclosed braces
- "echo {{.InvalidField}}" # Invalid template - unknown field
recycle: [] # Empty - triggers warning

hooks:
- pattern: "[invalid-regex" # Invalid regex - unclosed bracket
commands: [] # Empty commands - triggers warning
- pattern: "^https://github.com/.*"
commands: [] # Empty commands - triggers warning

keybindings:
w:
sh: "open {{.BadField}}" # Error: invalid template in sh
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea
github.com/hay-kot/criterio v1.0.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.6.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hay-kot/criterio v1.0.0 h1:zAyKMZqzqHLqltQD0sbCsOgjtr/Uca19ixlLlzgzLL0=
github.com/hay-kot/criterio v1.0.0/go.mod h1:3gRuIn3ahkBOQV0E/xIg37yXbs09lTJmDoRpD6Yfceg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
Expand Down
106 changes: 106 additions & 0 deletions internal/commands/cmd_doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package commands

import (
"context"
"encoding/json"

"github.com/hay-kot/hive/internal/commands/doctor"
"github.com/hay-kot/hive/internal/printer"
"github.com/urfave/cli/v3"
)

type DoctorCmd struct {
flags *Flags
format string
}

func NewDoctorCmd(flags *Flags) *DoctorCmd {
return &DoctorCmd{flags: flags}
}

func (cmd *DoctorCmd) Register(app *cli.Command) *cli.Command {
app.Commands = append(app.Commands, &cli.Command{
Name: "doctor",
Usage: "Run health checks on your hive setup",
UsageText: "hive doctor [options]",
Description: "Runs diagnostic checks on configuration, environment, and dependencies.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "format",
Usage: "output format (text, json)",
Value: "text",
Destination: &cmd.format,
},
},
Action: cmd.run,
})
return app
}

func (cmd *DoctorCmd) run(ctx context.Context, c *cli.Command) error {
checks := []doctor.Check{
doctor.NewConfigCheck(cmd.flags.Config, cmd.flags.ConfigPath),
}

results := doctor.RunAll(ctx, checks)

if cmd.format == "json" {
return cmd.outputJSON(c, results)
}

return cmd.outputText(ctx, results)
}

func (cmd *DoctorCmd) outputJSON(c *cli.Command, results []doctor.Result) error {
passed, warned, failed := doctor.Summary(results)

out := struct {
Healthy bool `json:"healthy"`
Summary summaryJSON `json:"summary"`
Checks []doctor.Result `json:"checks"`
}{
Healthy: failed == 0,
Summary: summaryJSON{Passed: passed, Warned: warned, Failed: failed},
Checks: results,
}

enc := json.NewEncoder(c.Root().Writer)
enc.SetIndent("", " ")
return enc.Encode(out)
}

type summaryJSON struct {
Passed int `json:"passed"`
Warned int `json:"warned"`
Failed int `json:"failed"`
}

func (cmd *DoctorCmd) outputText(ctx context.Context, results []doctor.Result) error {
p := printer.Ctx(ctx)

for _, result := range results {
p.Section(result.Name)

for _, item := range result.Items {
switch item.Status {
case doctor.StatusPass:
p.CheckItem(item.Label, item.Detail)
case doctor.StatusWarn:
p.WarnItem(item.Label, item.Detail)
case doctor.StatusFail:
p.FailItem(item.Label, item.Detail)
}
}

p.Printf("")
}

passed, warned, failed := doctor.Summary(results)
p.Printf("Summary: %d passed, %d warnings, %d failed", passed, warned, failed)

if failed > 0 {
return cli.Exit("", 1)
}

return nil
}
91 changes: 91 additions & 0 deletions internal/commands/doctor/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package doctor

import (
"context"
"errors"

"github.com/hay-kot/criterio"
"github.com/hay-kot/hive/internal/core/config"
)

// ConfigCheck validates the configuration file.
type ConfigCheck struct {
config *config.Config
configPath string
}

// NewConfigCheck creates a new configuration check.
func NewConfigCheck(cfg *config.Config, configPath string) *ConfigCheck {
return &ConfigCheck{
config: cfg,
configPath: configPath,
}
}

func (c *ConfigCheck) Name() string {
return "Configuration"
}

func (c *ConfigCheck) Run(ctx context.Context) Result {
result := Result{Name: c.Name()}

if c.config == nil {
result.Items = append(result.Items, CheckItem{
Label: "Config loaded",
Status: StatusFail,
Detail: "configuration not loaded",
})
return result
}

err := c.config.ValidateDeep(c.configPath)
warnings := c.config.Warnings()

// If no errors and no warnings, report success
if err == nil && len(warnings) == 0 {
result.Items = append(result.Items, CheckItem{
Label: "Config valid",
Status: StatusPass,
})
return result
}

// Extract and report errors
if err != nil {
var fieldErrs criterio.FieldErrors
if errors.As(err, &fieldErrs) {
for _, fe := range fieldErrs {
label := fe.Field
if label == "" {
label = "validation"
}
result.Items = append(result.Items, CheckItem{
Label: label,
Status: StatusFail,
Detail: fe.Err.Error(),
})
}
} else {
result.Items = append(result.Items, CheckItem{
Label: "validation",
Status: StatusFail,
Detail: err.Error(),
})
}
}

// Extract and report warnings
for _, w := range warnings {
label := w.Category
if w.Item != "" {
label += " (" + w.Item + ")"
}
result.Items = append(result.Items, CheckItem{
Label: label,
Status: StatusWarn,
Detail: w.Message,
})
}

return result
}
77 changes: 77 additions & 0 deletions internal/commands/doctor/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package doctor

import "context"

// Status represents the result status of a check item.
type Status int

const (
StatusPass Status = iota
StatusWarn
StatusFail
)

func (s Status) String() string {
switch s {
case StatusPass:
return "pass"
case StatusWarn:
return "warn"
case StatusFail:
return "fail"
default:
return "unknown"
}
}

// CheckItem represents a single line item within a check result.
type CheckItem struct {
Label string `json:"label"`
Status Status `json:"-"`
Detail string `json:"detail,omitempty"`

// For JSON output
StatusStr string `json:"status"`
}

// Result represents the outcome of a check containing multiple items.
type Result struct {
Name string `json:"name"`
Items []CheckItem `json:"items"`
}

// Check defines the interface for a doctor check.
type Check interface {
Name() string
Run(ctx context.Context) Result
}

// RunAll executes all checks and returns their results.
func RunAll(ctx context.Context, checks []Check) []Result {
results := make([]Result, 0, len(checks))
for _, check := range checks {
result := check.Run(ctx)
for i := range result.Items {
result.Items[i].StatusStr = result.Items[i].Status.String()
}
results = append(results, result)
}
return results
}

// Summary returns counts of passed, warned, and failed items across all results.
func Summary(results []Result) (passed, warned, failed int) {
for _, r := range results {
for _, item := range r.Items {
switch item.Status {
case StatusPass:
passed++
case StatusWarn:
warned++
case StatusFail:
failed++
}
}
}
return
}
Loading
Loading