diff --git a/README.md b/README.md index 2031ea4..2f4a491 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - plaintext exports in multiple formats, - support for all [binary data](https://github.com/Llywelwyn/pda#binary), - [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). @@ -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) - [Binary](https://github.com/Llywelwyn/pda#binary) - [Encryption](https://github.com/Llywelwyn/pda#encryption) +- [Doctor](https://github.com/Llywelwyn/pda#doctor) - [Environment](https://github.com/Llywelwyn/pda#environment)
@@ -99,6 +101,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + doctor Check environment health help Help about any command version Display pda! version ``` @@ -727,6 +730,36 @@ pda identity --new +### 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 +``` + + + +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. + + + ### Environment Config is stored in your user config directory in `pda/config.toml`. diff --git a/cmd/doctor.go b/cmd/doctor.go index 1e99748..f1a58af 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -2,10 +2,15 @@ package cmd import ( "fmt" + "io" "os" + "os/exec" "path/filepath" + "runtime" + "strings" "github.com/spf13/cobra" + "golang.org/x/term" ) var doctorCmd = &cobra.Command{ @@ -20,7 +25,17 @@ func init() { } 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 emit := func(level, msg string) { @@ -28,101 +43,316 @@ func doctor(cmd *cobra.Command, args []string) error { switch level { case "ok": code = "32" - case "info": - code = "34" + case "WARN": + code = "33" case "FAIL": code = "31" 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() if err != nil { 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 { - emit("ok", "Configuration loaded") + cfgDir := filepath.Dir(cfgPath) + envSuffix := "" + if os.Getenv("PDA_CONFIG") != "" { + envSuffix = " (PDA_CONFIG)" + } + + 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") + } } - // Data directory + // 7. Non-default config values + if diffs := configDiffs(); len(diffs) > 0 { + emit("ok", "Non-default config:") + tree(diffs) + } + + // 8. Data directory store := &Store{} dataDir, err := store.path() if err != nil { emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err)) } else { - emit("ok", "Data directory accessible") + envSuffix := "" + if os.Getenv("PDA_DATA") != "" { + envSuffix = " (PDA_DATA)" + } + + 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)) + } } - // Identity + // 9. Identity file idPath, err := identityPath() if err != nil { emit("FAIL", fmt.Sprintf("Cannot determine identity path: %v", 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 { emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err)) } 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 if dataDir != "" { gitDir := filepath.Join(dataDir, ".git") if _, err := os.Stat(gitDir); os.IsNotExist(err) { - emit("info", "Git not initialised") + emit("WARN", "Git not initialised") } else if err != nil { emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err)) } else { 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 { - hasOrigin, err := repoHasRemote(dataDir, "origin") + var err error + hasOrigin, err = repoHasRemote(dataDir, "origin") if err != nil { emit("FAIL", fmt.Sprintf("Cannot check git remote: %v", err)) } else if hasOrigin { emit("ok", "Git remote configured") } 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() if err != nil { emit("FAIL", fmt.Sprintf("Cannot list stores: %v", err)) } else if len(stores) == 0 { - emit("info", "No stores") + emit("WARN", "No stores found") } else { - var parseErrors int + var totalKeys, totalSecrets, parseErrors int + var totalSize int64 for _, name := range stores { p, pErr := store.storePath(name) if pErr != nil { parseErrors++ 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++ + continue + } + totalKeys += len(entries) + for _, e := range entries { + if e.Secret { + totalSecrets++ + } } } 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 { - 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 { - 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 } diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 0000000..22780d9 --- /dev/null +++ b/cmd/doctor_test.go @@ -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) + } +} diff --git a/testdata/doctor.ct b/testdata/doctor.ct deleted file mode 100644 index 0495da5..0000000 --- a/testdata/doctor.ct +++ /dev/null @@ -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)