feat(doctor): full implementation of doctor health checks

This commit is contained in:
Lewis Wynne 2026-02-11 21:44:35 +00:00
parent 0c5b73154d
commit 11276fcf25
4 changed files with 410 additions and 36 deletions

View file

@ -26,6 +26,7 @@
- plaintext exports in multiple formats, - plaintext exports in multiple formats,
- support for all [binary data](https://github.com/Llywelwyn/pda#binary), - support for all [binary data](https://github.com/Llywelwyn/pda#binary),
- [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support, - [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support,
- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor),
and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb).
@ -56,6 +57,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr
- [TTL](https://github.com/Llywelwyn/pda#ttl) - [TTL](https://github.com/Llywelwyn/pda#ttl)
- [Binary](https://github.com/Llywelwyn/pda#binary) - [Binary](https://github.com/Llywelwyn/pda#binary)
- [Encryption](https://github.com/Llywelwyn/pda#encryption) - [Encryption](https://github.com/Llywelwyn/pda#encryption)
- [Doctor](https://github.com/Llywelwyn/pda#doctor)
- [Environment](https://github.com/Llywelwyn/pda#environment) - [Environment](https://github.com/Llywelwyn/pda#environment)
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -99,6 +101,7 @@ Git commands:
Additional Commands: Additional Commands:
completion Generate the autocompletion script for the specified shell completion Generate the autocompletion script for the specified shell
doctor Check environment health
help Help about any command help Help about any command
version Display pda! version version Display pda! version
``` ```
@ -727,6 +730,36 @@ pda identity --new
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
### Doctor
`pda doctor` runs a set of health checks of your environment.
```bash
pda doctor
# ok pda! 2025.52 Christmas release (linux/amd64)
# ok OS: Linux 6.18.7-arch1-1
# ok Go: go1.23.0
# ok Git: 2.45.0
# ok Shell: /bin/zsh
# ok Config: /home/user/.config/pda
# ok Non-default config:
# ├── display_ascii_art: false
# └── git.auto_commit: true
# ok Data: /home/user/.local/share/pda/stores
# ok Identity: /home/user/.config/pda/identity.txt
# ok Git initialised on main
# ok Git remote configured
# ok Git in sync with remote
# ok 3 store(s), 15 key(s), 2 secret(s), 4.2k total
# ok No issues found
```
<p align="center"></p><!-- spacer -->
Severity levels are colour-coded: `ok` (green), `WARN` (yellow), and `FAIL` (red). Only `FAIL` produces a non-zero exit code. `WARN` is generally not a problem, but may mean some functionality isn't being made use of, like for example version control not having been initialised yet.
<p align="center"></p><!-- spacer -->
### Environment ### Environment
Config is stored in your user config directory in `pda/config.toml`. Config is stored in your user config directory in `pda/config.toml`.

View file

@ -2,10 +2,15 @@ package cmd
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
) )
var doctorCmd = &cobra.Command{ var doctorCmd = &cobra.Command{
@ -20,7 +25,17 @@ func init() {
} }
func doctor(cmd *cobra.Command, args []string) error { func doctor(cmd *cobra.Command, args []string) error {
tty := stdoutIsTerminal() if runDoctor(os.Stdout) {
os.Exit(1)
}
return nil
}
func runDoctor(w io.Writer) bool {
tty := false
if f, ok := w.(*os.File); ok {
tty = term.IsTerminal(int(f.Fd()))
}
hasError := false hasError := false
emit := func(level, msg string) { emit := func(level, msg string) {
@ -28,101 +43,316 @@ func doctor(cmd *cobra.Command, args []string) error {
switch level { switch level {
case "ok": case "ok":
code = "32" code = "32"
case "info": case "WARN":
code = "34" code = "33"
case "FAIL": case "FAIL":
code = "31" code = "31"
hasError = true hasError = true
} }
fmt.Fprintf(os.Stdout, "%s %s\n", keyword(code, level, tty), msg) fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg)
} }
// Config tree := func(items []string) {
for i, item := range items {
connector := "├── "
if i == len(items)-1 {
connector = "└── "
}
fmt.Fprintf(w, " %s%s\n", connector, item)
}
}
// 1. Version + platform
emit("ok", fmt.Sprintf("%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH))
// 2. OS detail
switch runtime.GOOS {
case "linux":
if out, err := exec.Command("uname", "-r").Output(); err == nil {
emit("ok", fmt.Sprintf("OS: Linux %s", strings.TrimSpace(string(out))))
}
case "darwin":
if out, err := exec.Command("sw_vers", "-productVersion").Output(); err == nil {
emit("ok", fmt.Sprintf("OS: macOS %s", strings.TrimSpace(string(out))))
}
}
// 3. Go version
emit("ok", fmt.Sprintf("Go: %s", runtime.Version()))
// 4. Git dependency
gitAvailable := false
if out, err := exec.Command("git", "--version").Output(); err == nil {
gitVer := strings.TrimSpace(string(out))
if after, ok := strings.CutPrefix(gitVer, "git version "); ok {
gitVer = after
}
emit("ok", fmt.Sprintf("Git: %s", gitVer))
gitAvailable = true
} else {
emit("WARN", "git not found on PATH")
}
// 5. Shell
if shell := os.Getenv("SHELL"); shell != "" {
emit("ok", fmt.Sprintf("Shell: %s", shell))
} else {
emit("WARN", "SHELL not set")
}
// 6. Config directory and file
cfgPath, err := configPath() cfgPath, err := configPath()
if err != nil { if err != nil {
emit("FAIL", fmt.Sprintf("Cannot determine config path: %v", err)) emit("FAIL", fmt.Sprintf("Cannot determine config path: %v", err))
} else if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
emit("info", "Using default configuration")
} else if err != nil {
emit("FAIL", fmt.Sprintf("Cannot access config: %v", err))
} else { } else {
emit("ok", "Configuration loaded") cfgDir := filepath.Dir(cfgPath)
envSuffix := ""
if os.Getenv("PDA_CONFIG") != "" {
envSuffix = " (PDA_CONFIG)"
} }
// Data directory var issues []string
if _, statErr := os.Stat(cfgPath); statErr != nil && !os.IsNotExist(statErr) {
issues = append(issues, fmt.Sprintf("Config file unreadable: %s", cfgPath))
}
if unexpectedFiles(cfgDir, map[string]bool{
"config.toml": true,
"identity.txt": true,
}) {
issues = append(issues, "Unexpected file(s) in directory")
}
if len(issues) > 0 {
emit("FAIL", fmt.Sprintf("Config: %s%s", cfgDir, envSuffix))
tree(issues)
} else {
emit("ok", fmt.Sprintf("Config: %s%s", cfgDir, envSuffix))
}
if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) {
emit("ok", "Using default configuration")
}
}
// 7. Non-default config values
if diffs := configDiffs(); len(diffs) > 0 {
emit("ok", "Non-default config:")
tree(diffs)
}
// 8. Data directory
store := &Store{} store := &Store{}
dataDir, err := store.path() dataDir, err := store.path()
if err != nil { if err != nil {
emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err)) emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err))
} else { } else {
emit("ok", "Data directory accessible") envSuffix := ""
if os.Getenv("PDA_DATA") != "" {
envSuffix = " (PDA_DATA)"
} }
// Identity if unexpectedDataFiles(dataDir) {
emit("FAIL", fmt.Sprintf("Data: %s%s", dataDir, envSuffix))
tree([]string{"Unexpected file(s) in directory"})
} else {
emit("ok", fmt.Sprintf("Data: %s%s", dataDir, envSuffix))
}
}
// 9. Identity file
idPath, err := identityPath() idPath, err := identityPath()
if err != nil { if err != nil {
emit("FAIL", fmt.Sprintf("Cannot determine identity path: %v", err)) emit("FAIL", fmt.Sprintf("Cannot determine identity path: %v", err))
} else if _, err := os.Stat(idPath); os.IsNotExist(err) { } else if _, err := os.Stat(idPath); os.IsNotExist(err) {
emit("info", "No identity file. One will be created on first encrypt.") emit("WARN", "No identity file found")
} else if err != nil { } else if err != nil {
emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err)) emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err))
} else { } else {
emit("ok", "Identity file present") info, _ := os.Stat(idPath)
emit("ok", fmt.Sprintf("Identity: %s", idPath))
if perm := info.Mode().Perm(); perm != 0o600 {
emit("WARN", fmt.Sprintf("Identity file permissions %04o (should be 0600)", perm))
}
if _, loadErr := loadIdentity(); loadErr != nil {
emit("WARN", fmt.Sprintf("Identity file invalid: %v", loadErr))
}
} }
// Git // 10. Git initialised
gitInitialised := false gitInitialised := false
if dataDir != "" { if dataDir != "" {
gitDir := filepath.Join(dataDir, ".git") gitDir := filepath.Join(dataDir, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) { if _, err := os.Stat(gitDir); os.IsNotExist(err) {
emit("info", "Git not initialised") emit("WARN", "Git not initialised")
} else if err != nil { } else if err != nil {
emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err)) emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err))
} else { } else {
gitInitialised = true gitInitialised = true
emit("ok", "Git initialised") branch, _ := currentBranch(dataDir)
if branch == "" {
branch = "unknown"
}
emit("ok", fmt.Sprintf("Git initialised on %s", branch))
} }
} }
// Git remote (only when git is initialised) // 11. Git uncommitted changes (only if git initialised)
if gitInitialised && gitAvailable {
ucCmd := exec.Command("git", "status", "--porcelain")
ucCmd.Dir = dataDir
if out, err := ucCmd.Output(); err == nil {
if trimmed := strings.TrimSpace(string(out)); trimmed != "" {
count := len(strings.Split(trimmed, "\n"))
emit("WARN", fmt.Sprintf("Git %d file(s) with uncommitted changes", count))
}
}
}
// 12. Git remote (only if git initialised)
hasOrigin := false
if gitInitialised { if gitInitialised {
hasOrigin, err := repoHasRemote(dataDir, "origin") var err error
hasOrigin, err = repoHasRemote(dataDir, "origin")
if err != nil { if err != nil {
emit("FAIL", fmt.Sprintf("Cannot check git remote: %v", err)) emit("FAIL", fmt.Sprintf("Cannot check git remote: %v", err))
} else if hasOrigin { } else if hasOrigin {
emit("ok", "Git remote configured") emit("ok", "Git remote configured")
} else { } else {
emit("info", "No git remote configured") emit("WARN", "No git remote configured")
} }
} }
// Stores // 13. Git sync (only if git initialised AND remote exists)
if gitInitialised && hasOrigin && gitAvailable {
info, err := repoRemoteInfo(dataDir)
if err != nil || info.Ref == "" {
emit("WARN", "Git sync status unknown")
} else {
ahead, behind, err := repoAheadBehind(dataDir, info.Ref)
if err != nil {
emit("WARN", "Git sync status unknown")
} else if ahead == 0 && behind == 0 {
emit("ok", "Git in sync with remote")
} else {
var parts []string
if ahead > 0 {
parts = append(parts, fmt.Sprintf("%d ahead", ahead))
}
if behind > 0 {
parts = append(parts, fmt.Sprintf("%d behind", behind))
}
emit("WARN", fmt.Sprintf("Git %s remote", strings.Join(parts, ", ")))
}
}
}
// 14. Stores summary
stores, err := store.AllStores() stores, err := store.AllStores()
if err != nil { if err != nil {
emit("FAIL", fmt.Sprintf("Cannot list stores: %v", err)) emit("FAIL", fmt.Sprintf("Cannot list stores: %v", err))
} else if len(stores) == 0 { } else if len(stores) == 0 {
emit("info", "No stores") emit("WARN", "No stores found")
} else { } else {
var parseErrors int var totalKeys, totalSecrets, parseErrors int
var totalSize int64
for _, name := range stores { for _, name := range stores {
p, pErr := store.storePath(name) p, pErr := store.storePath(name)
if pErr != nil { if pErr != nil {
parseErrors++ parseErrors++
continue continue
} }
if _, rErr := readStoreFile(p, nil); rErr != nil { if fi, sErr := os.Stat(p); sErr == nil {
totalSize += fi.Size()
}
entries, rErr := readStoreFile(p, nil)
if rErr != nil {
parseErrors++ parseErrors++
continue
}
totalKeys += len(entries)
for _, e := range entries {
if e.Secret {
totalSecrets++
}
} }
} }
if parseErrors > 0 { if parseErrors > 0 {
emit("FAIL", fmt.Sprintf("%d store(s), %d with errors", len(stores), parseErrors)) emit("FAIL", fmt.Sprintf("%d store(s), %d with parse errors", len(stores), parseErrors))
} else { } else {
emit("ok", fmt.Sprintf("%d store(s)", len(stores))) emit("ok", fmt.Sprintf("%d store(s), %d key(s), %d secret(s), %s total",
len(stores), totalKeys, totalSecrets, formatSize(int(totalSize))))
} }
} }
if hasError { if hasError {
os.Exit(1) emit("FAIL", "1 or more issues found")
} else {
emit("ok", "No issues found")
} }
return nil
return hasError
}
func configDiffs() []string {
def := defaultConfig()
var diffs []string
if config.DisplayAsciiArt != def.DisplayAsciiArt {
diffs = append(diffs, fmt.Sprintf("display_ascii_art: %v", config.DisplayAsciiArt))
}
if config.Key.AlwaysPromptDelete != def.Key.AlwaysPromptDelete {
diffs = append(diffs, fmt.Sprintf("key.always_prompt_delete: %v", config.Key.AlwaysPromptDelete))
}
if config.Key.AlwaysPromptOverwrite != def.Key.AlwaysPromptOverwrite {
diffs = append(diffs, fmt.Sprintf("key.always_prompt_overwrite: %v", config.Key.AlwaysPromptOverwrite))
}
if config.Store.DefaultStoreName != def.Store.DefaultStoreName {
diffs = append(diffs, fmt.Sprintf("store.default_store_name: %s", config.Store.DefaultStoreName))
}
if config.Store.AlwaysPromptDelete != def.Store.AlwaysPromptDelete {
diffs = append(diffs, fmt.Sprintf("store.always_prompt_delete: %v", config.Store.AlwaysPromptDelete))
}
if config.Store.AlwaysPromptOverwrite != def.Store.AlwaysPromptOverwrite {
diffs = append(diffs, fmt.Sprintf("store.always_prompt_overwrite: %v", config.Store.AlwaysPromptOverwrite))
}
if config.Git.AutoFetch != def.Git.AutoFetch {
diffs = append(diffs, fmt.Sprintf("git.auto_fetch: %v", config.Git.AutoFetch))
}
if config.Git.AutoCommit != def.Git.AutoCommit {
diffs = append(diffs, fmt.Sprintf("git.auto_commit: %v", config.Git.AutoCommit))
}
if config.Git.AutoPush != def.Git.AutoPush {
diffs = append(diffs, fmt.Sprintf("git.auto_push: %v", config.Git.AutoPush))
}
return diffs
}
func unexpectedFiles(dir string, allowed map[string]bool) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if !allowed[e.Name()] {
return true
}
}
return false
}
func unexpectedDataFiles(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
name := e.Name()
if e.IsDir() && name == ".git" {
continue
}
if !e.IsDir() && (name == ".gitignore" || filepath.Ext(name) == ".ndjson") {
continue
}
return true
}
return false
} }

118
cmd/doctor_test.go Normal file
View file

@ -0,0 +1,118 @@
package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestDoctorCleanEnv(t *testing.T) {
dataDir := t.TempDir()
configDir := t.TempDir()
t.Setenv("PDA_DATA", dataDir)
t.Setenv("PDA_CONFIG", configDir)
var buf bytes.Buffer
hasError := runDoctor(&buf)
out := buf.String()
if hasError {
t.Errorf("expected no errors, got hasError=true\noutput:\n%s", out)
}
for _, want := range []string{
version,
"Using default configuration",
"No identity file found",
"Git not initialised",
"No stores found",
} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output, got:\n%s", want, out)
}
}
}
func TestDoctorWithStores(t *testing.T) {
dataDir := t.TempDir()
configDir := t.TempDir()
t.Setenv("PDA_DATA", dataDir)
t.Setenv("PDA_CONFIG", configDir)
content := "{\"key\":\"foo\",\"value\":\"bar\",\"encoding\":\"text\"}\n" +
"{\"key\":\"baz\",\"value\":\"qux\",\"encoding\":\"text\"}\n"
if err := os.WriteFile(filepath.Join(dataDir, "test.ndjson"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
hasError := runDoctor(&buf)
out := buf.String()
if hasError {
t.Errorf("expected no errors, got hasError=true\noutput:\n%s", out)
}
if !strings.Contains(out, "1 store(s), 2 key(s), 0 secret(s)") {
t.Errorf("expected store summary in output, got:\n%s", out)
}
}
func TestDoctorIdentityPermissions(t *testing.T) {
dataDir := t.TempDir()
configDir := t.TempDir()
t.Setenv("PDA_DATA", dataDir)
t.Setenv("PDA_CONFIG", configDir)
idPath := filepath.Join(configDir, "identity.txt")
if err := os.WriteFile(idPath, []byte("placeholder\n"), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
runDoctor(&buf)
out := buf.String()
if !strings.Contains(out, "Identity:") {
t.Errorf("expected 'Identity:' in output, got:\n%s", out)
}
if !strings.Contains(out, "should be 0600") {
t.Errorf("expected permissions warning in output, got:\n%s", out)
}
}
func TestDoctorGitInitialised(t *testing.T) {
dataDir := t.TempDir()
configDir := t.TempDir()
t.Setenv("PDA_DATA", dataDir)
t.Setenv("PDA_CONFIG", configDir)
cmd := exec.Command("git", "init")
cmd.Dir = dataDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
cmd = exec.Command("git", "commit", "--allow-empty", "-m", "init")
cmd.Dir = dataDir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test",
"GIT_AUTHOR_EMAIL=test@test",
"GIT_COMMITTER_NAME=test",
"GIT_COMMITTER_EMAIL=test@test",
)
if err := cmd.Run(); err != nil {
t.Fatalf("git commit: %v", err)
}
var buf bytes.Buffer
runDoctor(&buf)
out := buf.String()
if !strings.Contains(out, "Git initialised on") {
t.Errorf("expected 'Git initialised on' in output, got:\n%s", out)
}
if !strings.Contains(out, "No git remote configured") {
t.Errorf("expected 'No git remote configured' in output, got:\n%s", out)
}
}

7
testdata/doctor.ct vendored
View file

@ -1,7 +0,0 @@
# Doctor reports environment health
$ pda doctor
info Using default configuration
ok Data directory accessible
ok Identity file present
info Git not initialised
ok 5 store(s)