diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index 8b7bff4..ae26bc2 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -15,6 +15,7 @@ import ( "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/availability" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/completion" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/cost" + "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/doctor" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/images" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/instancetypes" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/locations" @@ -55,6 +56,14 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op return cmd.Help() }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Agent mode always implies JSON output and no TUI. Apply + // this before the credential-skip check so commands that + // bypass Complete() (skills, mcp serve, auth show/use) still + // get the right output mode and suppress spinners. + if opts.Agent { + opts.Output = "json" + } + // Skip heavy credential resolution for commands that don't need it: // - mcp serve: defers auth to the first tool call // - auth show: diagnostic command that should work even without valid credentials @@ -74,6 +83,21 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op } return nil }, + PersistentPostRun: func(cmd *cobra.Command, _ []string) { + // Show version-update hint (best-effort, never fails the command). + if opts.Agent || opts.Output != "table" { + return + } + switch cmd.Name() { + case "update", "doctor", "completion": + return + } + latest, current, err := cmdutil.CheckVersion(cmd.Context()) + if err != nil { + return + } + cmdutil.PrintVersionHint(ioStreams.ErrOut, latest, current) + }, } // --version / -v flag: print rich version info. @@ -138,6 +162,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op Message: "Other Commands:", Commands: []*cobra.Command{ completion.NewCmdCompletion(ioStreams), + doctor.NewCmdDoctor(f, ioStreams), settings.NewCmdSettings(f, ioStreams), update.NewCmdUpdate(f, ioStreams), }, @@ -167,6 +192,8 @@ func skipCredentialResolution(cmd *cobra.Command) bool { return true case pName == "skills": return true + case cmd.Name() == "doctor" && pName == "verda": + return true } return false } diff --git a/internal/verda-cli/cmd/doctor/doctor.go b/internal/verda-cli/cmd/doctor/doctor.go new file mode 100644 index 0000000..86bf4c5 --- /dev/null +++ b/internal/verda-cli/cmd/doctor/doctor.go @@ -0,0 +1,306 @@ +package doctor + +import ( + "context" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// checkResult holds the outcome of a single diagnostic check. +type checkResult struct { + Name string `json:"name"` + Status string `json:"status"` // "ok", "warn", "fail", "skip" + Detail string `json:"detail,omitempty"` +} + +// report is the structured output for the doctor command. +type report struct { + Checks []checkResult `json:"checks"` +} + +// NewCmdDoctor creates the doctor diagnostic command. +func NewCmdDoctor(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Diagnose common issues", + Long: cmdutil.LongDesc(` + Run a series of diagnostic checks against your Verda CLI + installation and report any issues found. Checks include + credential configuration, API reachability, authentication, + CLI version, binary location, and directory permissions. + `), + Example: cmdutil.Examples(` + # Run all diagnostic checks + verda doctor + + # Output as JSON for scripting + verda doctor -o json + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Best-effort credential resolution so doctor works even + // when credentials are bad or missing. + f.Options().Complete() + return runDoctor(cmd, f, ioStreams) + }, + } +} + +func runDoctor(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams) error { + ctx := cmd.Context() + + // 1. Credentials found + credResult := checkCredentials(f) + + // 2. API reachable + apiResult := checkAPIReachable(ctx, f) + + // 3. Authentication valid (skip if creds or API failed) + authResult := checkAuthentication(f, credResult, apiResult) + + checks := []checkResult{ + credResult, + apiResult, + authResult, + checkCLIVersion(ctx), // 4. CLI up to date + checkBinaryInstalled(), // 5. Binary installed + checkTemplatesDir(), // 6. Templates directory + checkConfigDir(), // 7. Config directory + } + + r := report{Checks: checks} + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Doctor report:", r) + + if wrote, err := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), r); wrote { + return err + } + + // Human-readable table output. + for _, c := range checks { + symbol := statusSymbol(c.Status) + detail := "" + if c.Detail != "" { + detail = " (" + c.Detail + ")" + } + _, _ = fmt.Fprintf(ioStreams.Out, " %s %s%s\n", symbol, c.Name, detail) + } + + return nil +} + +// checkCredentials verifies that a credentials file exists and contains keys. +func checkCredentials(f cmdutil.Factory) checkResult { + name := "Credentials found" + + credFile := f.Options().AuthOptions.CredentialsFile + if credFile == "" { + var err error + credFile, err = clioptions.DefaultCredentialsFilePath() + if err != nil { + return checkResult{Name: name, Status: "fail", Detail: err.Error()} + } + } + + info, err := os.Stat(credFile) + if err != nil { + if os.IsNotExist(err) { + return checkResult{Name: name, Status: "fail", Detail: shortPath(credFile) + " not found"} + } + return checkResult{Name: name, Status: "fail", Detail: err.Error()} + } + if info.IsDir() { + return checkResult{Name: name, Status: "fail", Detail: shortPath(credFile) + " is a directory"} + } + + // File exists — check if keys are configured. + auth := f.Options().AuthOptions + if auth.ClientID == "" || auth.ClientSecret == "" { + return checkResult{Name: name, Status: "warn", Detail: shortPath(credFile) + " exists but credentials are missing or incomplete"} + } + + return checkResult{Name: name, Status: "ok", Detail: shortPath(credFile)} +} + +// checkAPIReachable sends a HEAD request to the API server. +func checkAPIReachable(ctx context.Context, f cmdutil.Factory) checkResult { + name := "API reachable" + server := f.Options().Server + + ctx, cancel := context.WithTimeout(ctx, f.Options().Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, server, http.NoBody) + if err != nil { + return checkResult{Name: name, Status: "fail", Detail: err.Error()} + } + + resp, err := f.HTTPClient().Do(req) + if err != nil { + return checkResult{Name: name, Status: "fail", Detail: err.Error()} + } + _ = resp.Body.Close() + + // Any HTTP response (even 4xx) means the server is reachable. + return checkResult{Name: name, Status: "ok", Detail: server} +} + +// checkAuthentication verifies that f.Token() returns a valid token. +func checkAuthentication(f cmdutil.Factory, cred, api checkResult) checkResult { + name := "Authentication valid" + + if cred.Status != "ok" || api.Status != "ok" { + return checkResult{Name: name, Status: "skip", Detail: "skipped"} + } + + token := f.Token() + if token == "" { + return checkResult{Name: name, Status: "fail", Detail: "could not obtain token"} + } + + return checkResult{Name: name, Status: "ok"} +} + +// checkCLIVersion compares the current version against the latest release. +func checkCLIVersion(ctx context.Context) checkResult { + name := "CLI up to date" + + latest, current, err := cmdutil.CheckVersion(ctx) + if err != nil { + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + if cmdutil.CompareVersions(latest, current) > 0 { + return checkResult{Name: name, Status: "warn", Detail: fmt.Sprintf("%s \u2192 %s available", current, latest)} + } + + return checkResult{Name: name, Status: "ok", Detail: current} +} + +// checkBinaryInstalled verifies the binary is in the recommended directory. +func checkBinaryInstalled() checkResult { + name := "Binary installed" + + exe, err := os.Executable() + if err != nil { + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + binDir, err := clioptions.VerdaBinDir() + if err != nil { + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + exeDir := filepath.Dir(exe) + if exeDir == binDir { + return checkResult{Name: name, Status: "ok", Detail: shortPath(exe)} + } + + return checkResult{Name: name, Status: "warn", Detail: fmt.Sprintf("running from %s, recommended: %s", shortPath(exe), shortPath(binDir))} +} + +// checkTemplatesDir checks the templates directory existence and permissions. +func checkTemplatesDir() checkResult { + name := "Templates directory" + + dir, err := cmdutil.TemplatesBaseDir() + if err != nil { + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + return checkDirPerms(name, dir) +} + +// checkConfigDir checks the config directory existence and permissions. +func checkConfigDir() checkResult { + name := "Config directory" + + dir, err := clioptions.VerdaDir() + if err != nil { + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + return checkDirPerms(name, dir) +} + +// checkDirPerms checks that a directory exists and has secure permissions. +// If the directory doesn't exist, it returns ok (not an error — it may not +// have been created yet). On Windows, permission checks are skipped. +func checkDirPerms(name, dir string) checkResult { + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return checkResult{Name: name, Status: "ok", Detail: shortPath(dir) + " (not created yet)"} + } + return checkResult{Name: name, Status: "warn", Detail: err.Error()} + } + + if runtime.GOOS == "windows" { + return checkResult{Name: name, Status: "ok", Detail: shortPath(dir)} + } + + if !info.IsDir() { + return checkResult{Name: name, Status: "warn", Detail: shortPath(dir) + " is not a directory"} + } + + if hasLoosePerms(info) { + return checkResult{ + Name: name, + Status: "warn", + Detail: fmt.Sprintf("%s has permissions %s, recommended: 0700", shortPath(dir), info.Mode().Perm()), + } + } + + return checkResult{Name: name, Status: "ok", Detail: shortPath(dir)} +} + +// hasLoosePerms reports whether group or other permission bits are set. +func hasLoosePerms(info fs.FileInfo) bool { + return info.Mode().Perm()&0o077 != 0 +} + +// statusSymbol returns a human-readable status indicator. +func statusSymbol(status string) string { + switch status { + case "ok": + return "\u2713" // ✓ + case "warn": + return "!" + case "fail": + return "\u2717" // ✗ + case "skip": + return "-" + default: + return "?" + } +} + +// shortPath replaces the user's home directory prefix with ~. +func shortPath(p string) string { + home, err := os.UserHomeDir() + if err != nil { + return p + } + if strings.HasPrefix(p, home+string(filepath.Separator)) { + return "~" + p[len(home):] + } + if p == home { + return "~" + } + return p +} diff --git a/internal/verda-cli/cmd/update/update.go b/internal/verda-cli/cmd/update/update.go index 2a3373d..a292146 100644 --- a/internal/verda-cli/cmd/update/update.go +++ b/internal/verda-cli/cmd/update/update.go @@ -441,8 +441,8 @@ func updateInstalledSkills(ctx context.Context, newBinary string, ioStreams cmdu return } - args := make([]string, 0, 4+len(state.Agents)) - args = append(args, "--agent", "skills", "install", "--force") + args := make([]string, 0, 6+len(state.Agents)) + args = append(args, "--agent", "-o", "json", "skills", "install", "--force") args = append(args, state.Agents...) cmd := exec.CommandContext(ctx, newBinary, args...) //nolint:gosec // newBinary is the just-installed verda binary diff --git a/internal/verda-cli/cmd/util/versionhint.go b/internal/verda-cli/cmd/util/versionhint.go new file mode 100644 index 0000000..b20d384 --- /dev/null +++ b/internal/verda-cli/cmd/util/versionhint.go @@ -0,0 +1,192 @@ +package util + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" + "github.com/verda-cloud/verdagostack/pkg/version" +) + +// VersionCache holds the result of the last version check so we can avoid +// hitting the GitHub API on every CLI invocation. +type VersionCache struct { + LatestVersion string `json:"latest_version"` + CheckedAt time.Time `json:"checked_at"` +} + +// IsStale reports whether the cache is older than ttl. +func (c *VersionCache) IsStale(ttl time.Duration) bool { + if c.LatestVersion == "" { + return true + } + return time.Since(c.CheckedAt) > ttl +} + +// VersionCachePath returns the path to ~/.verda/version-check.json. +func VersionCachePath() (string, error) { + dir, err := clioptions.VerdaDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "version-check.json"), nil +} + +// LoadVersionCache reads the version cache from disk. +// Returns an empty cache (no error) if the file is missing or corrupt. +func LoadVersionCache(path string) (*VersionCache, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + // Missing file is not an error — just return empty cache. + return &VersionCache{}, nil //nolint:nilerr // intentional: missing file → empty cache + } + var c VersionCache + if err := json.Unmarshal(data, &c); err != nil { + // Corrupt file — return empty cache. + return &VersionCache{}, nil //nolint:nilerr // intentional: corrupt file → empty cache + } + return &c, nil +} + +// SaveVersionCache writes the version cache to disk, creating parent +// directories with 0700 permissions if needed. +func SaveVersionCache(path string, c *VersionCache) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating cache directory: %w", err) + } + data, err := json.Marshal(c) + if err != nil { + return fmt.Errorf("marshaling version cache: %w", err) + } + return os.WriteFile(path, data, 0o644) //nolint:gosec // version cache is not sensitive +} + +// FetchLatestVersion queries the GitHub releases API for the latest release +// tag of verda-cli. +func FetchLatestVersion(ctx context.Context) (string, error) { + const url = "https://api.github.com/repos/verda-cloud/verda-cli/releases/latest" + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "verda-cli/"+version.Get().GitVersion) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() //nolint:errcheck // best-effort close + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API: HTTP %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("decoding release response: %w", err) + } + if release.TagName == "" { + return "", errors.New("no tag_name in release response") + } + return release.TagName, nil +} + +// CheckVersion loads the version cache, fetches if stale (24h TTL), saves the +// cache, and returns the latest and current versions. On fetch error it falls +// back to the cached value. +func CheckVersion(ctx context.Context) (latest, current string, err error) { + const ttl = 24 * time.Hour + + cachePath, err := VersionCachePath() + if err != nil { + return "", "", err + } + + cache, err := LoadVersionCache(cachePath) + if err != nil { + return "", "", err + } + + latest = cache.LatestVersion + + if cache.IsStale(ttl) { + fetched, fetchErr := FetchLatestVersion(ctx) + switch { + case fetchErr != nil && cache.LatestVersion == "": + return "", "", fetchErr + case fetchErr == nil: + latest = fetched + cache.LatestVersion = fetched + cache.CheckedAt = time.Now() + _ = SaveVersionCache(cachePath, cache) // best-effort + } + // fetchErr != nil && cache.LatestVersion != "": fall back to cached value + } + + current = version.Get().GitVersion + if !strings.HasPrefix(current, "v") { + current = "v" + current + } + + return latest, current, nil +} + +// PrintVersionHint prints an update hint to w if latest > current. +func PrintVersionHint(w io.Writer, latest, current string) { + if CompareVersions(latest, current) > 0 { + _, _ = fmt.Fprintf(w, "\nUpdate available: %s → %s — run 'verda update'\n", current, latest) + } +} + +// CompareVersions performs a simple semver comparison, returning -1, 0, or 1. +// It strips "v" prefixes and pre-release suffixes (e.g. "-dev"). +func CompareVersions(a, b string) int { + aParts := parseSemver(a) + bParts := parseSemver(b) + + for i := 0; i < 3; i++ { + if aParts[i] < bParts[i] { + return -1 + } + if aParts[i] > bParts[i] { + return 1 + } + } + return 0 +} + +// parseSemver strips "v" prefix and pre-release suffixes, then splits into +// three integer components [major, minor, patch]. +func parseSemver(s string) [3]int { + s = strings.TrimPrefix(s, "v") + parts := strings.SplitN(s, ".", 3) + + var result [3]int + for i := 0; i < len(parts) && i < 3; i++ { + // Strip pre-release suffix (e.g. "0-dev" -> "0"). + clean := parts[i] + if idx := strings.IndexByte(clean, '-'); idx >= 0 { + clean = clean[:idx] + } + n, _ := strconv.Atoi(clean) + result[i] = n + } + return result +} diff --git a/internal/verda-cli/cmd/util/versionhint_test.go b/internal/verda-cli/cmd/util/versionhint_test.go new file mode 100644 index 0000000..cb70b5b --- /dev/null +++ b/internal/verda-cli/cmd/util/versionhint_test.go @@ -0,0 +1,127 @@ +package util + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCompareVersions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b string + want int + }{ + {name: "v1.6.1 < v1.6.2", a: "v1.6.1", b: "v1.6.2", want: -1}, + {name: "equal versions", a: "v1.6.2", b: "v1.6.2", want: 0}, + {name: "v1.7.0 > v1.6.2", a: "v1.7.0", b: "v1.6.2", want: 1}, + {name: "v2.0.0 > v1.99.99", a: "v2.0.0", b: "v1.99.99", want: 1}, + {name: "without v prefix", a: "1.6.1", b: "1.6.2", want: -1}, + {name: "pre-release suffix stripped", a: "v1.0.0-dev", b: "v1.0.0", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CompareVersions(tt.a, tt.b) + if got != tt.want { + t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestVersionCache_RoundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "version-check.json") + + now := time.Now().Truncate(time.Second) // JSON loses sub-second precision + original := &VersionCache{ + LatestVersion: "v1.6.2", + CheckedAt: now, + } + + if err := SaveVersionCache(path, original); err != nil { + t.Fatalf("SaveVersionCache: %v", err) + } + + loaded, err := LoadVersionCache(path) + if err != nil { + t.Fatalf("LoadVersionCache: %v", err) + } + + if loaded.LatestVersion != original.LatestVersion { + t.Fatalf("LatestVersion = %q, want %q", loaded.LatestVersion, original.LatestVersion) + } + // Compare with second precision since JSON round-trips may lose nanos. + if loaded.CheckedAt.Unix() != original.CheckedAt.Unix() { + t.Fatalf("CheckedAt = %v, want %v", loaded.CheckedAt, original.CheckedAt) + } +} + +func TestVersionCache_MissingFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "nonexistent", "version-check.json") + + cache, err := LoadVersionCache(path) + if err != nil { + t.Fatalf("LoadVersionCache should not error on missing file, got: %v", err) + } + if cache.LatestVersion != "" { + t.Fatalf("LatestVersion should be empty, got %q", cache.LatestVersion) + } +} + +func TestVersionCache_Stale(t *testing.T) { + t.Parallel() + + ttl := 24 * time.Hour + + t.Run("25h old is stale", func(t *testing.T) { + c := &VersionCache{ + LatestVersion: "v1.0.0", + CheckedAt: time.Now().Add(-25 * time.Hour), + } + if !c.IsStale(ttl) { + t.Fatal("expected cache to be stale") + } + }) + + t.Run("1h old is fresh", func(t *testing.T) { + c := &VersionCache{ + LatestVersion: "v1.0.0", + CheckedAt: time.Now().Add(-1 * time.Hour), + } + if c.IsStale(ttl) { + t.Fatal("expected cache to be fresh") + } + }) + + t.Run("empty cache is stale", func(t *testing.T) { + c := &VersionCache{} + if !c.IsStale(ttl) { + t.Fatal("expected empty cache to be stale") + } + }) +} + +func TestSaveVersionCache_CreatesParentDirs(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "nested", "dir") + path := filepath.Join(dir, "version-check.json") + + c := &VersionCache{LatestVersion: "v1.0.0", CheckedAt: time.Now()} + if err := SaveVersionCache(path, c); err != nil { + t.Fatalf("SaveVersionCache should create parent dirs, got: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("file should exist: %v", err) + } +}