diff --git a/bindings/node/README.md b/bindings/node/README.md index 6483d9fb..4e62e3d2 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -84,6 +84,7 @@ ows sign tx --wallet agent-treasury --chain evm --tx "deadbeef..." | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | | `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | ## Architecture diff --git a/docs/internal/ows-doctor-plan.md b/docs/internal/ows-doctor-plan.md new file mode 100644 index 00000000..9b545e4f --- /dev/null +++ b/docs/internal/ows-doctor-plan.md @@ -0,0 +1,203 @@ +# `ows doctor` Design Note + +> Internal design document for the `ows doctor` diagnostic command. + +## Overview + +`ows doctor` is a read-only diagnostic command that inspects the local OWS installation and vault health. It does not mutate, repair, or modify any files. + +## 1. CLI Position + +`ows doctor` is a **top-level command** in `Commands`, positioned alongside `Update` and `Uninstall`: + +```rust +/// In main.rs +enum Commands { + // ... existing ... + Doctor, +} +``` + +Handler dispatches to `commands::doctor::run()`. + +## 2. V1 Checks + +| Check ID | Name | Description | +|----------|------|-------------| +| `vault_path` | Vault Path Resolution | Vault path derived from `Config::default()` | +| `vault_exists` | Vault Existence | Whether `~/.ows` directory exists | +| `wallets_dir` | Wallets Directory | Whether `wallets/` subdirectory exists and is readable | +| `config_parse` | Config Parse | Whether `config.json` exists and parses as `Config` | +| `wallet_files` | Wallet Files | Enumerate all `.json` files in `wallets/`, parse each as `EncryptedWallet` | +| `key_files` | Key Files | Enumerate all `.json` files in `keys/`, parse each as `ApiKeyFile` | +| `policy_files` | Policy Files | Enumerate all `.json` files in `policies/`, parse each as `Policy` | +| `vault_permissions` | Vault Permissions | Check directory and file permissions (Unix only, skip on Windows) | +| `runtime_deps` | Runtime Dependencies | Check for OpenSSL via `pkg-config` (Unix only) | +| `home_env` | HOME Environment | Whether `HOME` env var is set | + +## 3. Internal Data Model + +```rust +/// Status of a single check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DoctorStatus { + Pass, // Check succeeded + Warn, // Check passed but with concerns + Fail, // Check failed + Skip, // Check skipped (not applicable, e.g., no wallets yet) +} + +/// A single diagnostic check result. +#[derive(Debug)] +pub struct DoctorFinding { + pub id: &'static str, + pub status: DoctorStatus, + pub message: String, + pub remediation: Option, +} + +/// Aggregated diagnostic report. +#[derive(Debug, Default)] +pub struct DoctorReport { + pub findings: Vec, +} +``` + +**Key design decisions:** +- `Skip` is distinct from `Pass` to avoid alarming users about normal states (e.g., no wallets yet) +- `remediation` is `Option` — present for `Warn` and `Fail`, absent for `Pass` and `Skip` +- No severity enumeration (OK/WARN/ERROR/INFO) — V1 uses `Pass/Warn/Fail/Skip` only +- Report is just a collection of findings; grouping/sorting happens at display time + +## 4. Warning/Error Taxonomy + +V1 uses a **4-state model**: + +| State | Meaning | Exit Code Impact | +|-------|---------|------------------| +| `Pass` | Check succeeded, no issues | Continue (0) | +| `Skip` | Check not applicable (e.g., no wallets) | Continue (0) | +| `Warn` | Minor concern detected | Continue (0) | +| `Fail` | Critical issue detected | Exit (1) | + +**Rules:** +- If **any** `Fail` exists → exit code 1 +- Otherwise → exit code 0 + +## 5. Output Shape (Human-Readable) + +``` +OWS Doctor +========== + +[Pass] vault_path Vault resolved to ~/.ows +[Pass] vault_exists ~/.ows exists +[Pass] wallets_dir wallets/ present (2 wallets found) +[Pass] config_parse config.json valid (15 RPC endpoints) +[Pass] policy_files policies/ valid (1 policy) +[Skip] key_files keys/ not present +[Pass] vault_perms Permissions correct +[Pass] home_env HOME is set +[Pass] runtime_deps OpenSSL available + +Summary: 7 passed, 0 warnings, 0 failed, 2 skipped +``` + +**With warnings:** +``` +[Pass] vault_path Vault resolved to ~/.ows +[Pass] vault_exists ~/.ows exists +[Pass] wallets_dir wallets/ present (2 wallets found) +[Pass] config_parse config.json valid +[Warn] vault_perms ~/.ows has mode 0755, expected 0700 + → Run: chmod 700 ~/.ows + +Summary: 4 passed, 1 warning, 0 failed, 0 skipped +``` + +**With failures:** +``` +[Fail] wallet_files wallet-abc.json: JSON parse error at line 3 + → Backup and recreate the wallet + +Summary: 3 passed, 0 warnings, 1 failed, 0 skipped +``` + +**Key output rules:** +- No emoji in the check list (emoji only in summary banner) +- Remediation on the line below, indented with `→ ` +- No color codes (not a terminal UI feature) +- Grouped by status in output: Fail first, then Warn, then Pass, then Skip + +## 6. Testing Plan + +| Test | Scope | +|------|-------| +| `test_doctor_report_default` | Empty vault — all findings are Skip except vault_path/vault_exists | +| `test_doctor_report_missing_home` | HOME unset — `home_env` returns Warn | +| `test_doctor_report_permission_warning` | Vault dir mode 0755 — `vault_perms` returns Warn | +| `test_doctor_report_wallet_corrupted` | Invalid JSON in wallet file — `wallet_files` returns Fail | +| `test_doctor_report_multiple_findings` | Several findings at once — summary counts are correct | +| `test_doctor_exit_code_pass` | All findings Pass/Skip → exit 0 | +| `test_doctor_exit_code_fail` | Any Fail → exit 1 | +| `test_doctor_exit_code_warn_only` | Warn only → exit 0 | + +Tests live in `ows-cli/src/commands/doctor.rs` under `#[cfg(test)]`. + +## 7. V1 Out of Scope + +The following are explicitly **not** included in V1: + +- **File repair or mutation** — no `chmod`, no backup, no auto-fix +- **Wallet balance or RPC connectivity checks** — network calls; out of scope for local diagnostics +- **Deterministic JSON output** — human-readable only in V1; structured output (e.g., `--json`) is V2 +- **Per-wallet detailed report** — V1 shows counts only; individual wallet health is V2 +- **Policy rule validation** — V1 checks file parseability only; rule evaluation is V2 +- **Cross-vault migration checks** — no checking of `~/.lws` vs `~/.ows` +- **Dependency version checking** — `cargo`, `git`, Node.js version detection + +## 8. File Changes Plan + +| File | Change | +|------|--------| +| `ows/crates/ows-cli/src/commands/doctor.rs` | **New** — all diagnostic logic | +| `ows/crates/ows-cli/src/commands/mod.rs` | Add `pub mod doctor;` | +| `ows/crates/ows-cli/src/main.rs` | Add `Doctor` variant + handler | +| `docs/sdk-cli.md` | Add `### ows doctor` section under System Commands | + +## 9. Crate Boundaries + +``` +ows-cli (command layer) + └── calls: ows_lib::vault, ows_lib::policy_store + └── calls: ows_core::Config, ows_core::EncryptedWallet, ows_core::Policy, ows_core::ApiKeyFile + └── calls: ows_signer (no-op in doctor, just re-exports) + +ows-lib (storage layer) + └── vault.rs: resolve_vault_path(), wallets_dir(), list_encrypted_wallets() + └── policy_store.rs: policies_dir(), list_policies() + └── key_ops.rs: keys_dir() — NOT YET EXPOSED; may need to add + +ows-core (types layer) + └── Config, EncryptedWallet, Policy, ApiKeyFile +``` + +**Note:** `keys_dir()` is not currently exposed from `ows-lib`. It must be added to `ows-lib/src/vault.rs` and re-exported in `ows-lib/src/lib.rs` before `key_files` check can enumerate key files. + +## 10. Implementation Phases + +**Phase 1 (this PR):** +- Core model: `DoctorStatus`, `DoctorFinding`, `DoctorReport` +- All checks except `key_files` +- Human-readable output +- Unit tests +- Docs + +**Phase 2 (future):** +- Add `keys_dir()` to `ows-lib` +- `key_files` check +- `--json` structured output flag + +**Phase 3 (future):** +- Per-wallet detailed report +- Policy rule validation diff --git a/docs/sdk-cli.md b/docs/sdk-cli.md index 04abe1e3..23a629ff 100644 --- a/docs/sdk-cli.md +++ b/docs/sdk-cli.md @@ -380,6 +380,88 @@ ows uninstall # keep wallet data ows uninstall --purge # also remove ~/.ows (all wallet data) ``` +### `ows doctor` + +Run diagnostic checks on the OWS installation. `ows doctor` is a read-only command that checks: +- Vault path resolution and HOME environment variable +- Vault and subdirectory existence +- Config file validity (if present) +- Wallet, key, and policy file integrity +- File permissions (Unix only; skipped on Windows/macOS) + +**Exit code semantics:** +- `0` — all checks passed, or only warnings were found (no errors) +- non-zero — at least one check produced an error + +```bash +ows doctor +``` + +**Example: healthy vault** + +``` +============================================================ + OWS Doctor +============================================================ + + Vault path: ~/.ows + + Passed: + ---------------------------------------- + ✓ Vault path resolved + ✓ Vault exists + ✓ Logs directory present + ✓ Config file valid + + 4 passed 0 warnings 0 errors 6 skipped + + Result: OK — all checks passed +``` + +**Example: missing vault (first run)** + +``` +============================================================ + OWS Doctor +============================================================ + + Vault path: ~/.ows + + Errors: + ---------------------------------------- + ✗ Vault directory not found: No vault found at `~/.ows`. No wallets have been created yet. + → Run `ows wallet create` to create your first wallet. + + Skipped: + ---------------------------------------- + ○ Logs directory check skipped + ○ Config file not present + ○ No wallets directory + ○ No keys directory + ○ No policies directory + ○ Permissions check skipped + + 1 passed 0 warnings 1 errors 6 skipped + + Result: FAILED — errors found +``` + +**Example: corrupted wallet file** + +``` +Errors: + ---------------------------------------- + ✗ Wallet file is not valid JSON: bad.json: JSON parse error. This file is corrupted: key must be a string at line 1 column 3. + → Export the mnemonic (if possible) and recreate the wallet with `ows wallet create`. + +Warnings: + ---------------------------------------- + ⚠ Some wallet files are corrupted: 1 of 2 wallet file(s) are corrupted. + → Export the mnemonic from any valid wallets and recreate the corrupted ones. +``` + +**Platform caveats:** Permission checks (`vault.permissions`) only run on Unix/Linux systems. On Windows and macOS the check is reported as skipped with the note "Permission checks are Unix-only." + ## File Layout ``` diff --git a/ows/README.md b/ows/README.md index dcc8f228..0b8fd671 100644 --- a/ows/README.md +++ b/ows/README.md @@ -42,6 +42,7 @@ cargo build --workspace --release | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | | `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | ## Language Bindings diff --git a/ows/crates/ows-cli/README.md b/ows/crates/ows-cli/README.md index dcc8f228..0b8fd671 100644 --- a/ows/crates/ows-cli/README.md +++ b/ows/crates/ows-cli/README.md @@ -42,6 +42,7 @@ cargo build --workspace --release | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | | `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | ## Language Bindings diff --git a/ows/crates/ows-cli/src/commands/doctor/checks.rs b/ows/crates/ows-cli/src/commands/doctor/checks.rs new file mode 100644 index 00000000..b05b42cc --- /dev/null +++ b/ows/crates/ows-cli/src/commands/doctor/checks.rs @@ -0,0 +1,506 @@ +//! Individual diagnostic checks for `ows doctor`. + +use crate::commands::doctor::report::{ + DoctorCheckId, DoctorFinding, DoctorReport, OWS_DOCTOR_CONFIG_INVALID, + OWS_DOCTOR_CONFIG_MISSING, OWS_DOCTOR_DIR_UNREADABLE, OWS_DOCTOR_ENV_HOME_NOT_SET, + OWS_DOCTOR_LOGS_DIR_MISSING, OWS_DOCTOR_VAULT_MISSING, +}; +use crate::commands::doctor::vault_inspector; + +use ows_core::Config; + +// --------------------------------------------------------------------------- +// Check IDs +// --------------------------------------------------------------------------- + +/// Vault path resolution check ID. +pub const CHECK_VAULT_PATH: DoctorCheckId = DoctorCheckId::new("vault.path"); +/// Vault existence check ID. +pub const CHECK_VAULT_EXISTS: DoctorCheckId = DoctorCheckId::new("vault.exists"); +/// Logs directory presence check ID. +pub const CHECK_LOGS_DIR: DoctorCheckId = DoctorCheckId::new("vault.logs_dir"); +/// Config file presence and parseability check ID. +pub const CHECK_CONFIG: DoctorCheckId = DoctorCheckId::new("config.parse"); +/// Vault directory permissions check ID (Unix only). +pub const CHECK_VAULT_PERMS: DoctorCheckId = DoctorCheckId::new("vault.permissions"); +/// HOME environment variable check ID. +pub const CHECK_HOME_ENV: DoctorCheckId = DoctorCheckId::new("env.home"); + +// --------------------------------------------------------------------------- +// Vault path resolution +// --------------------------------------------------------------------------- + +/// Resolve the vault path from `Config::default()`. +/// +/// Exposed for use by vault_inspector functions in tests. +pub fn resolve_vault_path() -> std::path::PathBuf { + Config::default().vault_path +} + +// --------------------------------------------------------------------------- +// Check implementations +// --------------------------------------------------------------------------- + +/// Check that HOME is set and the vault path resolves correctly. +pub fn check_vault_path() -> Vec { + let mut findings = Vec::new(); + + let home = std::env::var("HOME").ok(); + if home.is_none() { + findings.push(DoctorFinding::error( + CHECK_HOME_ENV, + OWS_DOCTOR_ENV_HOME_NOT_SET, + "HOME environment variable is not set", + "Vault path resolution may be unreliable without HOME. Set HOME to your user directory.", + "Set HOME to your user home directory (e.g. HOME=/home/alice). OWS derives the vault path as $HOME/.ows.", + )); + } + + let vault_path = resolve_vault_path(); + + findings.push(DoctorFinding::ok( + CHECK_VAULT_PATH, + "Vault path resolved", + &format!("Vault path resolved to `{}`.", vault_path.display()), + )); + + findings +} + +/// Check that the vault directory exists. +pub fn check_vault_exists() -> Vec { + let vault_path = resolve_vault_path(); + + if vault_path.exists() { + vec![DoctorFinding::ok( + CHECK_VAULT_EXISTS, + "Vault exists", + &format!("Vault directory is present at `{}`.", vault_path.display()), + )] + } else { + vec![DoctorFinding::error( + CHECK_VAULT_EXISTS, + OWS_DOCTOR_VAULT_MISSING, + "Vault directory not found", + &format!( + "No vault found at `{}`. No wallets have been created yet.", + vault_path.display() + ), + "Run `ows wallet create` to create your first wallet.", + ) + .with_path(vault_path.clone())] + } +} + +/// Check that the logs subdirectory exists (if vault exists). +pub fn check_logs_dir() -> Vec { + let vault_path = resolve_vault_path(); + + if !vault_path.exists() { + return vec![DoctorFinding::skipped( + CHECK_LOGS_DIR, + OWS_DOCTOR_LOGS_DIR_MISSING, + "Logs directory check skipped", + "Vault does not exist; skipping logs directory check.", + )]; + } + + let logs_dir = vault_path.join("logs"); + + if logs_dir.exists() { + vec![DoctorFinding::ok( + CHECK_LOGS_DIR, + "Logs directory present", + &format!("Audit log directory exists at `{}`.", logs_dir.display()), + ) + .with_path(logs_dir)] + } else { + vec![DoctorFinding::skipped( + CHECK_LOGS_DIR, + OWS_DOCTOR_LOGS_DIR_MISSING, + "Logs directory not present", + "logs/ does not exist. Audit logging may not be active.", + )] + } +} + +/// Check that the config file is present and parseable. +pub fn check_config() -> Vec { + let config_path = resolve_vault_path().join("config.json"); + + if !config_path.exists() { + return vec![DoctorFinding::skipped( + CHECK_CONFIG, + OWS_DOCTOR_CONFIG_MISSING, + "Config file not present", + "No user config at `~/.ows/config.json`. Built-in defaults are in use.", + )]; + } + + // Use Config::load to get proper error handling for malformed JSON + match Config::load(&config_path) { + Ok(config) => { + let rpc_count = config.rpc.len(); + vec![DoctorFinding::ok( + CHECK_CONFIG, + "Config file valid", + &format!( + "config.json is valid with {} RPC endpoint(s) configured.", + rpc_count + ), + ) + .with_path(config_path)] + } + Err(e) => { + vec![DoctorFinding::error( + CHECK_CONFIG, + OWS_DOCTOR_CONFIG_INVALID, + "Config file is invalid", + &format!("config.json is present but could not be parsed: {}.", e), + "Backup and recreate `~/.ows/config.json`.", + ) + .with_path(config_path)] + } + } +} + +/// Check vault directory permissions (Unix only). +#[cfg(unix)] +pub fn check_vault_permissions() -> Vec { + use crate::commands::doctor::report::{ + OWS_DOCTOR_PERM_VAULT_INSECURE, OWS_DOCTOR_PERM_WALLETS_INSECURE, + OWS_DOCTOR_PERM_WALLET_FILE_INSECURE, + }; + use std::os::unix::fs::PermissionsExt; + + let vault_path = resolve_vault_path(); + + if !vault_path.exists() { + return vec![DoctorFinding::skipped( + CHECK_VAULT_PERMS, + OWS_DOCTOR_DIR_UNREADABLE, + "Permissions check skipped", + "Vault does not exist; skipping permissions check.", + )]; + } + + let mut findings = Vec::new(); + + // Check vault directory permissions + if let Ok(meta) = std::fs::metadata(&vault_path) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + findings.push( + DoctorFinding::warning( + CHECK_VAULT_PERMS, + OWS_DOCTOR_PERM_VAULT_INSECURE, + "Vault directory permissions are insecure", + &format!( + "Vault has mode {:03o}; owner-only (0700) is required.", + mode + ), + "Run: chmod 700 ~/.ows", + ) + .with_path(vault_path.clone()), + ); + } else { + findings.push( + DoctorFinding::ok( + CHECK_VAULT_PERMS, + "Vault directory permissions are correct", + "Vault directory has secure permissions (0700).", + ) + .with_path(vault_path.clone()), + ); + } + } + + // Check wallets directory permissions + let wallets_dir = vault_path.join("wallets"); + if let Ok(meta) = std::fs::metadata(&wallets_dir) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + findings.push( + DoctorFinding::warning( + CHECK_VAULT_PERMS, + OWS_DOCTOR_PERM_WALLETS_INSECURE, + "Wallets directory permissions are insecure", + &format!( + "wallets/ has mode {:03o}; owner-only (0700) is required.", + mode + ), + "Run: chmod 700 ~/.ows/wallets", + ) + .with_path(wallets_dir.clone()), + ); + } + } + + // Check wallet file permissions + if let Ok(entries) = std::fs::read_dir(&wallets_dir) { + for entry in entries.filter_map(|e| e.ok()) { + if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(meta) = std::fs::metadata(entry.path()) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o600 { + let file_name = entry.file_name().to_string_lossy(); + findings.push( + DoctorFinding::warning( + CHECK_VAULT_PERMS, + OWS_DOCTOR_PERM_WALLET_FILE_INSECURE, + "Wallet file permissions are insecure", + &format!( + "{} has mode {:03o}; owner-read-only (0600) is required.", + file_name, mode + ), + &format!("Run: chmod 600 ~/.ows/wallets/{}", file_name), + ) + .with_path(entry.path()), + ); + } + } + } + } + } + + if findings.is_empty() { + vec![DoctorFinding::ok( + CHECK_VAULT_PERMS, + "Permissions check passed", + "All vault and wallet permissions are correct.", + )] + } else { + findings + } +} + +/// Check vault directory permissions (Windows stub — no-op). +#[cfg(not(unix))] +pub fn check_vault_permissions() -> Vec { + vec![DoctorFinding::skipped( + CHECK_VAULT_PERMS, + OWS_DOCTOR_DIR_UNREADABLE, + "Permissions check skipped", + "Permission checks are Unix-only.", + )] +} + +// --------------------------------------------------------------------------- +// Check runner +// --------------------------------------------------------------------------- + +/// Run all diagnostic checks and return the aggregated report. +/// +/// Checks run in a fixed order. Each check is independent and produces +/// zero or more findings. All findings are collected into a single report. +pub fn run_all_checks() -> DoctorReport { + let vault_path = resolve_vault_path(); + + let mut all_findings = Vec::new(); + + // Path resolution and HOME check + all_findings.extend(check_vault_path()); + + // Vault existence + all_findings.extend(check_vault_exists()); + + // Logs directory (optional) + all_findings.extend(check_logs_dir()); + + // Config + all_findings.extend(check_config()); + + // Wallet, key, and policy file inspection + all_findings.extend(vault_inspector::check_wallet_files(&vault_path)); + all_findings.extend(vault_inspector::check_key_files(&vault_path)); + all_findings.extend(vault_inspector::check_policy_files(&vault_path)); + + // Permissions (platform-specific) + all_findings.extend(check_vault_permissions()); + + DoctorReport::new(all_findings) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::doctor::vault_inspector; + use crate::commands::doctor::DoctorStatus; + + #[test] + fn test_run_all_checks_returns_valid_report() { + // Use a temporary vault path + let temp = tempfile::TempDir::new().unwrap(); + let vault = temp.path().to_path_buf(); + std::fs::create_dir(vault.join("wallets")).ok(); + std::fs::create_dir(vault.join("policies")).ok(); + std::fs::create_dir(vault.join("keys")).ok(); + + // Add a valid wallet + let wallet = ows_core::EncryptedWallet::new( + "test-id".to_string(), + "Test".to_string(), + vec![], + serde_json::json!({}), + ows_core::KeyType::Mnemonic, + ); + let json = serde_json::to_string_pretty(&wallet).unwrap(); + std::fs::write(vault.join("wallets/test.json"), json).ok(); + + // Add a valid policy + let policy = ows_core::Policy { + id: "test-policy".to_string(), + name: "Test".to_string(), + version: 1, + created_at: "2026-01-01T00:00:00Z".to_string(), + rules: vec![], + executable: None, + config: None, + action: ows_core::PolicyAction::Deny, + }; + let json = serde_json::to_string_pretty(&policy).unwrap(); + std::fs::write(vault.join("policies/test.json"), json).ok(); + + // Run checks with the temp vault path + std::env::set_var("HOME", temp.path()); + + let all_results = vault_inspector::check_wallet_files(&vault); + assert!(!all_results.is_empty()); + + let all_policies = vault_inspector::check_policy_files(&vault); + assert!(!all_policies.is_empty()); + + let all_keys = vault_inspector::check_key_files(&vault); + assert!(!all_keys.is_empty()); + + // Report aggregation + let mut findings = Vec::new(); + findings.extend(vault_inspector::check_wallet_files(&vault)); + findings.extend(vault_inspector::check_key_files(&vault)); + findings.extend(vault_inspector::check_policy_files(&vault)); + findings.extend(check_config()); + findings.extend(check_logs_dir()); + + let report = DoctorReport::new(findings); + assert!(report.findings.iter().any(|f| f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_wallet_inspection_empty_dir_warning() { + let temp = tempfile::TempDir::new().unwrap(); + let vault = temp.path().to_path_buf(); + std::fs::create_dir(vault.join("wallets")).ok(); + + let findings = vault_inspector::check_wallet_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Warning)); + } + + #[test] + fn test_wallet_inspection_malformed_json() { + use crate::commands::doctor::report::OWS_DOCTOR_WALLET_FILE_INVALID; + let temp = tempfile::TempDir::new().unwrap(); + let vault = temp.path().to_path_buf(); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + std::fs::write(wallets_dir.join("bad.json"), "{ invalid }").ok(); + + let findings = vault_inspector::check_wallet_files(&vault); + assert!(findings + .iter() + .any(|f| f.code == Some(OWS_DOCTOR_WALLET_FILE_INVALID))); + } + + #[test] + fn test_wallet_inspection_valid() { + let temp = tempfile::TempDir::new().unwrap(); + let vault = temp.path().to_path_buf(); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + + let wallet = ows_core::EncryptedWallet::new( + "test-wallet".to_string(), + "Test Wallet".to_string(), + vec![], + serde_json::json!({}), + ows_core::KeyType::Mnemonic, + ); + let json = serde_json::to_string_pretty(&wallet).unwrap(); + std::fs::write(wallets_dir.join("test.json"), json).ok(); + + let findings = vault_inspector::check_wallet_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_policy_inspection_valid() { + let temp = tempfile::TempDir::new().unwrap(); + let vault = temp.path().to_path_buf(); + let policies_dir = vault.join("policies"); + std::fs::create_dir_all(&policies_dir).ok(); + + let policy = ows_core::Policy { + id: "test-policy".to_string(), + name: "Test Policy".to_string(), + version: 1, + created_at: "2026-01-01T00:00:00Z".to_string(), + rules: vec![], + executable: None, + config: None, + action: ows_core::PolicyAction::Deny, + }; + let json = serde_json::to_string_pretty(&policy).unwrap(); + std::fs::write(policies_dir.join("test.json"), json).ok(); + + let findings = vault_inspector::check_policy_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_key_inspection_valid() { + let temp = tempfile::TempDir::new().unwrap(); + let vault = temp.path().to_path_buf(); + let keys_dir = vault.join("keys"); + std::fs::create_dir_all(&keys_dir).ok(); + + let key = ows_core::ApiKeyFile { + id: "test-key".to_string(), + name: "Test Key".to_string(), + token_hash: "deadbeef".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + wallet_ids: vec![], + policy_ids: vec![], + expires_at: None, + wallet_secrets: std::collections::HashMap::new(), + }; + let json = serde_json::to_string_pretty(&key).unwrap(); + std::fs::write(keys_dir.join("test.json"), json).ok(); + + let findings = vault_inspector::check_key_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_home_not_set_remediation_text() { + // Regression: HOME not-set remediation must not suggest HOME=~/.ows + // (that would nest the vault path as $HOME/.ows/.ows) + std::env::remove_var("HOME"); + let findings = check_vault_path(); + let home_error = findings + .iter() + .find(|f| f.code == Some(OWS_DOCTOR_ENV_HOME_NOT_SET)) + .expect("HOME not-set must produce a finding"); + + assert!( + !home_error.suggestion.as_ref().unwrap().contains("~/.ows"), + "HOME remediation must not suggest HOME=~/.ows" + ); + assert!( + home_error + .suggestion + .as_ref() + .unwrap() + .contains("$HOME/.ows"), + "HOME remediation must explain vault is derived as $HOME/.ows" + ); + } +} diff --git a/ows/crates/ows-cli/src/commands/doctor/mod.rs b/ows/crates/ows-cli/src/commands/doctor/mod.rs new file mode 100644 index 00000000..d7749cb6 --- /dev/null +++ b/ows/crates/ows-cli/src/commands/doctor/mod.rs @@ -0,0 +1,148 @@ +//! `ows doctor` — Diagnostic command for OWS installation health. +//! +//! This module provides a read-only diagnostic system that checks: +//! - Vault path resolution +//! - Vault and subdirectory existence +//! - Config file validity +//! - File permissions (Unix) +//! - Wallet, key, and policy file integrity +//! - Environment configuration +//! +//! All checks are read-only and do not modify any files. +//! +//! # Architecture +//! +//! - [`report`] — Domain types: `DoctorStatus`, `DoctorFinding`, `DoctorReport` +//! - [`checks`] — Individual check implementations +//! - [`vault_inspector`] — Read-only vault artifact inspection (wallets/keys/policies) +//! +//! # Stability +//! +//! The check IDs, status enums, and report structure are considered stable +//! and will not change in a breaking way. Output formatting is separate +//! and can evolve independently. + +pub mod checks; +pub mod report; +pub mod vault_inspector; + +// Re-exports for CLI and tests. +#[allow(unused)] +pub use report::{DoctorCheckId, DoctorFinding, DoctorReport, DoctorStatus, DoctorSummary}; + +use crate::CliError; + +/// Run the `ows doctor` diagnostic command. +/// +/// Executes all checks, formats the report as human-readable output, +/// and returns the appropriate exit code via `Err` when errors are found. +pub fn run() -> Result<(), CliError> { + let report = checks::run_all_checks(); + print_report(&report); + + if report.has_errors() { + Err(CliError::InvalidArgs("diagnostic checks failed".into())) + } else { + Ok(()) + } +} + +/// Print a human-readable diagnostic report to stdout. +fn print_report(report: &DoctorReport) { + use ows_core::Config; + + println!(); + println!("{}", "=".repeat(60)); + println!(" OWS Doctor"); + println!("{}", "=".repeat(60)); + println!(); + + // Vault path + let config = Config::default(); + println!(" Vault path: {}", config.vault_path.display()); + println!(); + + // Group findings by status + let errors: Vec<_> = report.findings_with_status(DoctorStatus::Error); + let warnings: Vec<_> = report.findings_with_status(DoctorStatus::Warning); + let skipped: Vec<_> = report.findings_with_status(DoctorStatus::Skipped); + let ok: Vec<_> = report.findings_with_status(DoctorStatus::Ok); + + // Print errors first + if !errors.is_empty() { + println!(" Errors:"); + println!(" {}", "-".repeat(40)); + for f in &errors { + print_finding(f); + } + println!(); + } + + // Then warnings + if !warnings.is_empty() { + println!(" Warnings:"); + println!(" {}", "-".repeat(40)); + for f in &warnings { + print_finding(f); + } + println!(); + } + + // Then skipped (informational) + if !skipped.is_empty() { + println!(" Skipped:"); + println!(" {}", "-".repeat(40)); + for f in &skipped { + print_finding(f); + } + println!(); + } + + // Then ok findings (brief, condensed) + if !ok.is_empty() { + println!(" Passed:"); + println!(" {}", "-".repeat(40)); + for f in &ok { + println!(" {} {}", status_icon(DoctorStatus::Ok), f.title); + } + println!(); + } + + // Summary + println!("{}", "=".repeat(60)); + println!( + " {} passed {} warnings {} errors {} skipped", + report.summary.ok, report.summary.warnings, report.summary.errors, report.summary.skipped + ); + println!(); + + if report.has_errors() { + println!(" Result: FAILED — errors found"); + } else if report.has_warnings() { + println!( + " Result: OK — {} warning(s) found", + report.summary.warnings + ); + } else if report.summary.ok == 0 && report.summary.skipped > 0 { + println!(" Result: SKIPPED — no checks could run"); + } else { + println!(" Result: OK — all checks passed"); + } + println!(); +} + +fn print_finding(f: &DoctorFinding) { + println!(" {} {}: {}", status_icon(f.status), f.title, f.detail); + if let Some(ref suggestion) = f.suggestion { + println!(" → {}", suggestion); + } +} + +fn status_icon(status: DoctorStatus) -> &'static str { + match status { + DoctorStatus::Ok => "✓", + DoctorStatus::Warning => "⚠", + DoctorStatus::Error => "✗", + DoctorStatus::Skipped => "○", + } +} diff --git a/ows/crates/ows-cli/src/commands/doctor/report.rs b/ows/crates/ows-cli/src/commands/doctor/report.rs new file mode 100644 index 00000000..04f46efc --- /dev/null +++ b/ows/crates/ows-cli/src/commands/doctor/report.rs @@ -0,0 +1,437 @@ +//! Diagnostic report types for `ows doctor`. + +use std::fmt; + +// --------------------------------------------------------------------------- +// Stable finding codes +// --------------------------------------------------------------------------- + +// Taxonomy: every actionable finding carries a stable code. Ok findings +// (purely informational, no action needed) do not use codes. +// +// Prefix map: +// OWS_DOCTOR_ENV_* — environment / path resolution +// OWS_DOCTOR_VAULT_* — vault-level structural checks +// OWS_DOCTOR_CONFIG_* — config file parsing +// OWS_DOCTOR_PERM_* — Unix file permissions +// OWS_DOCTOR_WALLET_* — wallet file validation +// OWS_DOCTOR_POLICY_* — policy file validation +// OWS_DOCTOR_KEY_* — API key file validation + +/// HOME environment variable is not set. +pub const OWS_DOCTOR_ENV_HOME_NOT_SET: &str = "OWS_DOCTOR_ENV_HOME_NOT_SET"; +/// Vault directory does not exist. +pub const OWS_DOCTOR_VAULT_MISSING: &str = "OWS_DOCTOR_VAULT_MISSING"; +/// Vault logs subdirectory is absent. +pub const OWS_DOCTOR_LOGS_DIR_MISSING: &str = "OWS_DOCTOR_LOGS_DIR_MISSING"; +/// Config file is absent; built-in defaults are in use. +pub const OWS_DOCTOR_CONFIG_MISSING: &str = "OWS_DOCTOR_CONFIG_MISSING"; +/// Config file is present but malformed (invalid JSON or schema). +pub const OWS_DOCTOR_CONFIG_INVALID: &str = "OWS_DOCTOR_CONFIG_INVALID"; +/// A vault subdirectory cannot be read due to permissions or I/O errors. +pub const OWS_DOCTOR_DIR_UNREADABLE: &str = "OWS_DOCTOR_DIR_UNREADABLE"; +/// Vault directory permissions are insecure (Unix). +#[allow(dead_code)] +pub const OWS_DOCTOR_PERM_VAULT_INSECURE: &str = "OWS_DOCTOR_PERM_VAULT_INSECURE"; +/// wallets/ directory permissions are insecure (Unix). +#[allow(dead_code)] +pub const OWS_DOCTOR_PERM_WALLETS_INSECURE: &str = "OWS_DOCTOR_PERM_WALLETS_INSECURE"; +/// A wallet file has permissions insecure for a secret file (Unix). +#[allow(dead_code)] +pub const OWS_DOCTOR_PERM_WALLET_FILE_INSECURE: &str = "OWS_DOCTOR_PERM_WALLET_FILE_INSECURE"; +/// No wallet files present in the vault. +pub const OWS_DOCTOR_WALLET_NONE: &str = "OWS_DOCTOR_WALLET_NONE"; +/// A wallet file cannot be read. +pub const OWS_DOCTOR_WALLET_FILE_UNREADABLE: &str = "OWS_DOCTOR_WALLET_FILE_UNREADABLE"; +/// A wallet file is not valid JSON. +pub const OWS_DOCTOR_WALLET_FILE_INVALID: &str = "OWS_DOCTOR_WALLET_FILE_INVALID"; +/// A wallet file has invalid or missing metadata (empty ID, empty/invalid created_at). +pub const OWS_DOCTOR_WALLET_METADATA_CORRUPT: &str = "OWS_DOCTOR_WALLET_METADATA_CORRUPT"; +/// Some wallet files are corrupted while others are valid. +pub const OWS_DOCTOR_WALLET_SOME_CORRUPT: &str = "OWS_DOCTOR_WALLET_SOME_CORRUPT"; +/// No policy files present. +pub const OWS_DOCTOR_POLICY_NONE: &str = "OWS_DOCTOR_POLICY_NONE"; +/// A policy file cannot be read. +pub const OWS_DOCTOR_POLICY_FILE_UNREADABLE: &str = "OWS_DOCTOR_POLICY_FILE_UNREADABLE"; +/// A policy file is not valid JSON. +pub const OWS_DOCTOR_POLICY_FILE_INVALID: &str = "OWS_DOCTOR_POLICY_FILE_INVALID"; +/// Some policy files are corrupted while others are valid. +pub const OWS_DOCTOR_POLICY_SOME_CORRUPT: &str = "OWS_DOCTOR_POLICY_SOME_CORRUPT"; +/// No API key files present. +pub const OWS_DOCTOR_KEY_NONE: &str = "OWS_DOCTOR_KEY_NONE"; +/// An API key file cannot be read. +pub const OWS_DOCTOR_KEY_FILE_UNREADABLE: &str = "OWS_DOCTOR_KEY_FILE_UNREADABLE"; +/// An API key file is not valid JSON. +pub const OWS_DOCTOR_KEY_FILE_INVALID: &str = "OWS_DOCTOR_KEY_FILE_INVALID"; +/// Some API key files are corrupted while others are valid. +pub const OWS_DOCTOR_KEY_SOME_CORRUPT: &str = "OWS_DOCTOR_KEY_SOME_CORRUPT"; + +// --------------------------------------------------------------------------- +// Check IDs +// --------------------------------------------------------------------------- + +/// Unique identifier for a diagnostic check. +/// +/// Check IDs are stable, dotted identifiers used to group findings +/// and as a stable anchor for structured output (future JSON mode). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DoctorCheckId(&'static str); + +impl DoctorCheckId { + pub const fn new(code: &'static str) -> Self { + DoctorCheckId(code) + } + + pub fn as_str(&self) -> &'static str { + self.0 + } +} + +impl fmt::Display for DoctorCheckId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// --------------------------------------------------------------------------- +// Status +// -------------------------------------------------------------------------- + +/// Status of a single diagnostic check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DoctorStatus { + /// Check succeeded with no issues. + Ok, + /// Check passed but a minor concern was detected. + Warning, + /// Check failed; a problem requires attention. + Error, + /// Check was skipped because it does not apply on this platform + /// or because the prerequisite state is absent. + Skipped, +} + +// --------------------------------------------------------------------------- +// Finding +// -------------------------------------------------------------------------- + +/// A single diagnostic finding from one check. +#[derive(Debug, Clone)] +pub struct DoctorFinding { + /// Unique identifier for the check that produced this finding. + pub id: DoctorCheckId, + /// Status of the check. + pub status: DoctorStatus, + /// Short title for the finding (suitable for display). + pub title: String, + /// Detailed explanation of the finding. + pub detail: String, + /// Actionable suggestion for remediation, if applicable. + pub suggestion: Option, + /// Path to the file or directory involved, if applicable. + pub path: Option, + /// Stable code for machine processing. Always present for Error, + /// Warning, and Skipped findings. Absent for informational Ok findings. + pub code: Option<&'static str>, +} + +impl DoctorFinding { + /// Create an informational Ok finding (no code needed). + pub fn ok(id: DoctorCheckId, title: &str, detail: &str) -> Self { + DoctorFinding { + id, + status: DoctorStatus::Ok, + title: title.to_string(), + detail: detail.to_string(), + suggestion: None, + path: None, + code: None, + } + } + + /// Create a Skipped finding with a stable code. + pub fn skipped(id: DoctorCheckId, code: &'static str, title: &str, detail: &str) -> Self { + DoctorFinding { + id, + status: DoctorStatus::Skipped, + title: title.to_string(), + detail: detail.to_string(), + suggestion: None, + path: None, + code: Some(code), + } + } + + /// Create a Warning finding with a stable code. + pub fn warning( + id: DoctorCheckId, + code: &'static str, + title: &str, + detail: &str, + suggestion: &str, + ) -> Self { + DoctorFinding { + id, + status: DoctorStatus::Warning, + title: title.to_string(), + detail: detail.to_string(), + suggestion: Some(suggestion.to_string()), + path: None, + code: Some(code), + } + } + + /// Create an Error finding with a stable code. + pub fn error( + id: DoctorCheckId, + code: &'static str, + title: &str, + detail: &str, + suggestion: &str, + ) -> Self { + DoctorFinding { + id, + status: DoctorStatus::Error, + title: title.to_string(), + detail: detail.to_string(), + suggestion: Some(suggestion.to_string()), + path: None, + code: Some(code), + } + } + + /// Builder-style method to attach a path to the finding. + pub fn with_path(mut self, path: std::path::PathBuf) -> Self { + self.path = Some(path); + self + } +} + +/// Summary counts across all findings. +#[derive(Debug, Clone, Default)] +pub struct DoctorSummary { + pub ok: usize, + pub warnings: usize, + pub errors: usize, + pub skipped: usize, +} + +impl DoctorSummary { + pub fn total(&self) -> usize { + self.ok + self.warnings + self.errors + self.skipped + } +} + +/// Aggregated diagnostic report from all checks. +#[derive(Debug, Clone)] +pub struct DoctorReport { + /// The most severe status across all findings. + pub overall_status: DoctorStatus, + /// All individual findings in the order they were produced. + pub findings: Vec, + /// Summary counts. + pub summary: DoctorSummary, +} + +impl DoctorReport { + /// Create a new report from a list of findings. + /// + /// Findings are preserved in order. Overall status is derived as: + /// - `Error` if any error exists + /// - `Warning` if any warning exists (and no errors) + /// - `Ok` if only ok/skipped findings + /// - `Skipped` if only skipped findings + pub fn new(findings: Vec) -> Self { + let summary = DoctorSummary { + ok: findings + .iter() + .filter(|f| f.status == DoctorStatus::Ok) + .count(), + warnings: findings + .iter() + .filter(|f| f.status == DoctorStatus::Warning) + .count(), + errors: findings + .iter() + .filter(|f| f.status == DoctorStatus::Error) + .count(), + skipped: findings + .iter() + .filter(|f| f.status == DoctorStatus::Skipped) + .count(), + }; + + let overall_status = if summary.errors > 0 { + DoctorStatus::Error + } else if summary.warnings > 0 { + DoctorStatus::Warning + } else if summary.ok == 0 && summary.skipped > 0 { + DoctorStatus::Skipped + } else { + DoctorStatus::Ok + }; + + DoctorReport { + overall_status, + findings, + summary, + } + } + + /// Return true if the report indicates any errors. + pub fn has_errors(&self) -> bool { + self.summary.errors > 0 + } + + /// Return true if the report indicates any warnings. + pub fn has_warnings(&self) -> bool { + self.summary.warnings > 0 + } + + /// Return the appropriate exit code for this report. + pub fn exit_code(&self) -> i32 { + if self.has_errors() { + 1 + } else { + 0 + } + } + + /// Return findings filtered by a given status. + pub fn findings_with_status(&self, status: DoctorStatus) -> Vec<&DoctorFinding> { + self.findings + .iter() + .filter(|f| f.status == status) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: DoctorCheckId = DoctorCheckId::new("test.check"); + + #[test] + fn test_summary_counts() { + let findings = vec![ + DoctorFinding::ok(ID, "Good", "All good"), + DoctorFinding::skipped(ID, OWS_DOCTOR_VAULT_MISSING, "Skipped", "Not applicable"), + DoctorFinding::warning(ID, OWS_DOCTOR_WALLET_NONE, "Warn", "Minor issue", "Fix it"), + ]; + let report = DoctorReport::new(findings); + assert_eq!(report.summary.ok, 1); + assert_eq!(report.summary.warnings, 1); + assert_eq!(report.summary.errors, 0); + assert_eq!(report.summary.skipped, 1); + assert_eq!(report.summary.total(), 3); + } + + #[test] + fn test_overall_status_error_wins() { + let findings = vec![ + DoctorFinding::ok(ID, "Good", "All good"), + DoctorFinding::error(ID, OWS_DOCTOR_VAULT_MISSING, "Bad", "Critical", "Fix it"), + DoctorFinding::warning(ID, OWS_DOCTOR_WALLET_NONE, "Warn", "Minor", "Fix it"), + ]; + let report = DoctorReport::new(findings); + assert_eq!(report.overall_status, DoctorStatus::Error); + assert!(report.has_errors()); + assert!(report.has_warnings()); + assert_eq!(report.exit_code(), 1); + } + + #[test] + fn test_overall_status_warning_without_error() { + let findings = vec![ + DoctorFinding::ok(ID, "Good", "All good"), + DoctorFinding::warning(ID, OWS_DOCTOR_WALLET_NONE, "Warn", "Minor", "Fix it"), + ]; + let report = DoctorReport::new(findings); + assert_eq!(report.overall_status, DoctorStatus::Warning); + assert!(!report.has_errors()); + assert!(report.has_warnings()); + assert_eq!(report.exit_code(), 0); + } + + #[test] + fn test_overall_status_all_skipped() { + let findings = vec![ + DoctorFinding::skipped(ID, OWS_DOCTOR_VAULT_MISSING, "Skipped", "Not applicable"), + DoctorFinding::skipped( + ID, + OWS_DOCTOR_CONFIG_MISSING, + "Skipped 2", + "Also not applicable", + ), + ]; + let report = DoctorReport::new(findings); + assert_eq!(report.overall_status, DoctorStatus::Skipped); + assert!(!report.has_errors()); + assert!(!report.has_warnings()); + assert_eq!(report.exit_code(), 0); + } + + #[test] + fn test_overall_status_mixed_but_passing() { + let findings = vec![ + DoctorFinding::ok(ID, "Good", "All good"), + DoctorFinding::skipped(ID, OWS_DOCTOR_CONFIG_MISSING, "Skipped", "Not applicable"), + ]; + let report = DoctorReport::new(findings); + assert_eq!(report.overall_status, DoctorStatus::Ok); + assert!(!report.has_errors()); + assert!(!report.has_warnings()); + assert_eq!(report.exit_code(), 0); + } + + #[test] + fn test_findings_with_status() { + let findings = vec![ + DoctorFinding::ok(ID, "Good", "All good"), + DoctorFinding::warning(ID, OWS_DOCTOR_WALLET_NONE, "Warn", "Minor", "Fix it"), + DoctorFinding::error(ID, OWS_DOCTOR_VAULT_MISSING, "Bad", "Critical", "Fix it"), + DoctorFinding::skipped(ID, OWS_DOCTOR_CONFIG_MISSING, "Skipped", "Not applicable"), + ]; + let report = DoctorReport::new(findings); + assert_eq!(report.findings_with_status(DoctorStatus::Ok).len(), 1); + assert_eq!(report.findings_with_status(DoctorStatus::Warning).len(), 1); + assert_eq!(report.findings_with_status(DoctorStatus::Error).len(), 1); + assert_eq!(report.findings_with_status(DoctorStatus::Skipped).len(), 1); + } + + #[test] + fn test_finding_builder_with_path() { + let finding = DoctorFinding::ok(ID, "Title", "Detail") + .with_path(std::path::PathBuf::from("/test/path")); + assert!(finding.path.is_some()); + assert_eq!( + finding.path.unwrap(), + std::path::PathBuf::from("/test/path") + ); + } + + #[test] + fn test_skipped_has_code() { + let finding = + DoctorFinding::skipped(ID, OWS_DOCTOR_VAULT_MISSING, "Skipped", "Vault absent"); + assert_eq!(finding.code, Some(OWS_DOCTOR_VAULT_MISSING)); + assert_eq!(finding.status, DoctorStatus::Skipped); + } + + #[test] + fn test_error_has_code() { + let finding = + DoctorFinding::error(ID, OWS_DOCTOR_VAULT_MISSING, "Title", "Detail", "Fix it"); + assert_eq!(finding.code, Some(OWS_DOCTOR_VAULT_MISSING)); + assert_eq!(finding.status, DoctorStatus::Error); + } + + #[test] + fn test_doctor_check_id_display() { + let id = DoctorCheckId::new("vault.exists"); + assert_eq!(id.to_string(), "vault.exists"); + assert_eq!(id.as_str(), "vault.exists"); + } +} diff --git a/ows/crates/ows-cli/src/commands/doctor/vault_inspector.rs b/ows/crates/ows-cli/src/commands/doctor/vault_inspector.rs new file mode 100644 index 00000000..a47dabae --- /dev/null +++ b/ows/crates/ows-cli/src/commands/doctor/vault_inspector.rs @@ -0,0 +1,699 @@ +//! Read-only vault artifact inspection for `ows doctor`. + +use std::fs; +use std::path::Path; + +use ows_core::{ApiKeyFile, EncryptedWallet, Policy}; + +use crate::commands::doctor::report::{ + DoctorCheckId, DoctorFinding, OWS_DOCTOR_DIR_UNREADABLE, OWS_DOCTOR_KEY_FILE_INVALID, + OWS_DOCTOR_KEY_FILE_UNREADABLE, OWS_DOCTOR_KEY_NONE, OWS_DOCTOR_KEY_SOME_CORRUPT, + OWS_DOCTOR_POLICY_FILE_INVALID, OWS_DOCTOR_POLICY_FILE_UNREADABLE, OWS_DOCTOR_POLICY_NONE, + OWS_DOCTOR_POLICY_SOME_CORRUPT, OWS_DOCTOR_WALLET_FILE_INVALID, + OWS_DOCTOR_WALLET_FILE_UNREADABLE, OWS_DOCTOR_WALLET_METADATA_CORRUPT, OWS_DOCTOR_WALLET_NONE, + OWS_DOCTOR_WALLET_SOME_CORRUPT, +}; + +// --------------------------------------------------------------------------- +// Check IDs +// --------------------------------------------------------------------------- + +pub const CHECK_WALLET_FILES: DoctorCheckId = DoctorCheckId::new("vault.wallet_files"); +pub const CHECK_KEY_FILES: DoctorCheckId = DoctorCheckId::new("vault.key_files"); +pub const CHECK_POLICY_FILES: DoctorCheckId = DoctorCheckId::new("vault.policy_files"); + +// --------------------------------------------------------------------------- +// Wallet file inspection +// --------------------------------------------------------------------------- + +/// Inspect all wallet files in the vault. +/// +/// Returns one finding per artifact, plus a summary finding. +pub fn check_wallet_files(vault_path: &Path) -> Vec { + let wallets_dir = vault_path.join("wallets"); + + if !wallets_dir.exists() { + return vec![DoctorFinding::skipped( + CHECK_WALLET_FILES, + OWS_DOCTOR_DIR_UNREADABLE, + "No wallets directory", + "Wallets directory does not exist; skipping wallet inspection.", + )]; + } + + let entries: Vec<_> = match fs::read_dir(&wallets_dir) { + Ok(e) => e.filter_map(|e| e.ok()).collect(), + Err(e) => { + return vec![DoctorFinding::error( + CHECK_WALLET_FILES, + OWS_DOCTOR_DIR_UNREADABLE, + "Cannot read wallets directory", + &format!("Wallets directory exists but cannot be read: {}.", e), + "Check directory permissions.", + ) + .with_path(wallets_dir)]; + } + }; + + // Filter to only .json files + let json_entries: Vec<_> = entries + .into_iter() + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json")) + .collect(); + + if json_entries.is_empty() { + return vec![DoctorFinding::warning( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_NONE, + "No wallet files found", + "The wallets directory exists but contains no wallet files.", + "Run `ows wallet create` to create your first wallet.", + ) + .with_path(wallets_dir)]; + } + + let mut findings = Vec::new(); + let mut valid_count = 0; + let mut corrupted_count = 0; + + for entry in json_entries { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + // Try to read the file + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + corrupted_count += 1; + findings.push( + DoctorFinding::error( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_FILE_UNREADABLE, + "Wallet file cannot be read", + &format!("{}: I/O error reading file: {}.", file_name, e), + "Check file permissions with `ls -l ~/.ows/wallets/`.", + ) + .with_path(path), + ); + continue; + } + }; + + // Try to parse as EncryptedWallet + let wallet: EncryptedWallet = match serde_json::from_str(&contents) { + Ok(w) => w, + Err(e) => { + corrupted_count += 1; + findings.push(DoctorFinding::error( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_FILE_INVALID, + "Wallet file is not valid JSON", + &format!( + "{}: JSON parse error. This file is corrupted: {}.", + file_name, e + ), + "Export the mnemonic (if possible) and recreate the wallet with `ows wallet create`.", + ) + .with_path(path)); + continue; + } + }; + + // Additional metadata validation + if wallet.id.is_empty() { + findings.push( + DoctorFinding::error( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_METADATA_CORRUPT, + "Wallet has an empty ID field", + &format!("{}: the wallet `id` field is empty.", file_name), + "Export the mnemonic and recreate the wallet with `ows wallet create`.", + ) + .with_path(path), + ); + corrupted_count += 1; + continue; + } + + if wallet.created_at.is_empty() { + findings.push( + DoctorFinding::error( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_METADATA_CORRUPT, + "Wallet has an empty created_at field", + &format!("{}: the `created_at` field is empty.", file_name), + "Export the mnemonic and recreate the wallet with `ows wallet create`.", + ) + .with_path(path), + ); + corrupted_count += 1; + continue; + } + + // Validate created_at is valid RFC3339 + if chrono::DateTime::parse_from_rfc3339(&wallet.created_at).is_err() { + findings.push( + DoctorFinding::error( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_METADATA_CORRUPT, + "Wallet has an invalid created_at field", + &format!( + "{}: `created_at` is not valid RFC3339: `{}`.", + file_name, wallet.created_at + ), + "Export the mnemonic and recreate the wallet with `ows wallet create`.", + ) + .with_path(path), + ); + corrupted_count += 1; + continue; + } + + valid_count += 1; + } + + // Summary finding + if corrupted_count == 0 { + findings.push(DoctorFinding::ok( + CHECK_WALLET_FILES, + "All wallet files are valid", + &format!("{} wallet file(s) parsed successfully.", valid_count), + )); + } else { + findings.push(DoctorFinding::warning( + CHECK_WALLET_FILES, + OWS_DOCTOR_WALLET_SOME_CORRUPT, + "Some wallet files are corrupted", + &format!( + "{} of {} wallet file(s) are corrupted.", + corrupted_count, + valid_count + corrupted_count + ), + "Export the mnemonic from any valid wallets and recreate the corrupted ones.", + )); + } + + findings +} + +// --------------------------------------------------------------------------- +// Key file inspection +// --------------------------------------------------------------------------- + +/// Inspect all API key files in the vault. +/// +/// Returns one finding per artifact, plus a summary finding. +pub fn check_key_files(vault_path: &Path) -> Vec { + let keys_dir = vault_path.join("keys"); + + if !keys_dir.exists() { + return vec![DoctorFinding::skipped( + CHECK_KEY_FILES, + OWS_DOCTOR_DIR_UNREADABLE, + "No keys directory", + "Keys directory does not exist; skipping API key file inspection.", + )]; + } + + let entries: Vec<_> = match fs::read_dir(&keys_dir) { + Ok(e) => e.filter_map(|e| e.ok()).collect(), + Err(e) => { + return vec![DoctorFinding::error( + CHECK_KEY_FILES, + OWS_DOCTOR_DIR_UNREADABLE, + "Cannot read keys directory", + &format!("Keys directory exists but cannot be read: {}.", e), + "Check directory permissions.", + ) + .with_path(keys_dir)]; + } + }; + + let json_entries: Vec<_> = entries + .into_iter() + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json")) + .collect(); + + if json_entries.is_empty() { + return vec![DoctorFinding::skipped( + CHECK_KEY_FILES, + OWS_DOCTOR_KEY_NONE, + "No API key files found", + "The keys directory is empty.", + ) + .with_path(keys_dir)]; + } + + let mut findings = Vec::new(); + let mut valid_count = 0; + let mut corrupted_count = 0; + + for entry in json_entries { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + corrupted_count += 1; + findings.push( + DoctorFinding::error( + CHECK_KEY_FILES, + OWS_DOCTOR_KEY_FILE_UNREADABLE, + "API key file cannot be read", + &format!("{}: I/O error reading file: {}.", file_name, e), + "Check file permissions with `ls -l ~/.ows/keys/`.", + ) + .with_path(path), + ); + continue; + } + }; + + let _key_file: ApiKeyFile = match serde_json::from_str(&contents) { + Ok(k) => k, + Err(e) => { + corrupted_count += 1; + findings.push(DoctorFinding::error( + CHECK_KEY_FILES, + OWS_DOCTOR_KEY_FILE_INVALID, + "API key file is not valid JSON", + &format!("{}: JSON parse error. This file is corrupted: {}.", file_name, e), + "Delete and recreate the API key with `ows key revoke` then `ows key create`.", + ) + .with_path(path)); + continue; + } + }; + + valid_count += 1; + } + + if corrupted_count == 0 { + findings.push(DoctorFinding::ok( + CHECK_KEY_FILES, + "All API key files are valid", + &format!("{} API key file(s) parsed successfully.", valid_count), + )); + } else { + findings.push(DoctorFinding::warning( + CHECK_KEY_FILES, + OWS_DOCTOR_KEY_SOME_CORRUPT, + "Some API key files are corrupted", + &format!( + "{} of {} API key file(s) are corrupted.", + corrupted_count, + valid_count + corrupted_count + ), + "Delete and recreate the corrupted API keys.", + )); + } + + findings +} + +// --------------------------------------------------------------------------- +// Policy file inspection +// --------------------------------------------------------------------------- + +/// Inspect all policy files in the vault. +/// +/// Returns one finding per artifact, plus a summary finding. +pub fn check_policy_files(vault_path: &Path) -> Vec { + let policies_dir = vault_path.join("policies"); + + if !policies_dir.exists() { + return vec![DoctorFinding::skipped( + CHECK_POLICY_FILES, + OWS_DOCTOR_DIR_UNREADABLE, + "No policies directory", + "Policies directory does not exist; skipping policy file inspection.", + )]; + } + + let entries: Vec<_> = match fs::read_dir(&policies_dir) { + Ok(e) => e.filter_map(|e| e.ok()).collect(), + Err(e) => { + return vec![DoctorFinding::error( + CHECK_POLICY_FILES, + OWS_DOCTOR_DIR_UNREADABLE, + "Cannot read policies directory", + &format!("Policies directory exists but cannot be read: {}.", e), + "Check directory permissions.", + ) + .with_path(policies_dir)]; + } + }; + + let json_entries: Vec<_> = entries + .into_iter() + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json")) + .collect(); + + if json_entries.is_empty() { + return vec![DoctorFinding::skipped( + CHECK_POLICY_FILES, + OWS_DOCTOR_POLICY_NONE, + "No policy files found", + "The policies directory is empty.", + ) + .with_path(policies_dir)]; + } + + let mut findings = Vec::new(); + let mut valid_count = 0; + let mut corrupted_count = 0; + + for entry in json_entries { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + corrupted_count += 1; + findings.push( + DoctorFinding::error( + CHECK_POLICY_FILES, + OWS_DOCTOR_POLICY_FILE_UNREADABLE, + "Policy file cannot be read", + &format!("{}: I/O error reading file: {}.", file_name, e), + "Check file permissions with `ls -l ~/.ows/policies/`.", + ) + .with_path(path), + ); + continue; + } + }; + + let _policy: Policy = match serde_json::from_str(&contents) { + Ok(p) => p, + Err(e) => { + corrupted_count += 1; + findings.push( + DoctorFinding::error( + CHECK_POLICY_FILES, + OWS_DOCTOR_POLICY_FILE_INVALID, + "Policy file is not valid JSON", + &format!( + "{}: JSON parse error. This file is corrupted: {}.", + file_name, e + ), + "Recreate the policy with `ows policy create`.", + ) + .with_path(path), + ); + continue; + } + }; + + valid_count += 1; + } + + if corrupted_count == 0 { + findings.push(DoctorFinding::ok( + CHECK_POLICY_FILES, + "All policy files are valid", + &format!("{} policy file(s) parsed successfully.", valid_count), + )); + } else { + findings.push(DoctorFinding::warning( + CHECK_POLICY_FILES, + OWS_DOCTOR_POLICY_SOME_CORRUPT, + "Some policy files are corrupted", + &format!( + "{} of {} policy file(s) are corrupted.", + corrupted_count, + valid_count + corrupted_count + ), + "Recreate the corrupted policies.", + )); + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::doctor::DoctorStatus; + use tempfile::TempDir; + + fn dummy_wallet(id: &str, name: &str) -> EncryptedWallet { + EncryptedWallet::new( + id.to_string(), + name.to_string(), + vec![], + serde_json::json!({}), + ows_core::KeyType::Mnemonic, + ) + } + + fn dummy_key(id: &str, name: &str) -> ApiKeyFile { + ApiKeyFile { + id: id.to_string(), + name: name.to_string(), + token_hash: "deadbeef".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + wallet_ids: vec![], + policy_ids: vec![], + expires_at: None, + wallet_secrets: std::collections::HashMap::new(), + } + } + + fn dummy_policy(id: &str, name: &str) -> Policy { + Policy { + id: id.to_string(), + name: name.to_string(), + version: 1, + created_at: "2026-01-01T00:00:00Z".to_string(), + rules: vec![], + executable: None, + config: None, + action: ows_core::PolicyAction::Deny, + } + } + + // ---- Wallet tests ---- + + #[test] + fn test_wallet_files_skipped_when_dir_missing() { + let temp = TempDir::new().unwrap(); + let findings = check_wallet_files(temp.path()); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].status, DoctorStatus::Skipped); + assert_eq!(findings[0].code, Some(OWS_DOCTOR_DIR_UNREADABLE)); + } + + #[test] + fn test_wallet_files_empty_dir_is_warning() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + std::fs::create_dir_all(vault.join("wallets")).ok(); + let findings = check_wallet_files(&vault); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].status, DoctorStatus::Warning); + assert_eq!(findings[0].code, Some(OWS_DOCTOR_WALLET_NONE)); + } + + #[test] + fn test_wallet_files_one_valid() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + let wallet = dummy_wallet("wallet-1", "Test Wallet"); + let json = serde_json::to_string_pretty(&wallet).unwrap(); + std::fs::write(wallets_dir.join("wallet-1.json"), json).ok(); + + let findings = check_wallet_files(&vault); + // Should have 2 findings: one for the wallet, one summary + assert!(findings + .iter() + .any(|f| f.status == DoctorStatus::Ok && f.detail.contains("1 wallet"))); + assert!(findings + .iter() + .any(|f| f.id == CHECK_WALLET_FILES && f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_wallet_files_malformed_json() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + std::fs::write(wallets_dir.join("bad.json"), "{ invalid json }").ok(); + + let findings = check_wallet_files(&vault); + assert!(findings.iter().any(|f| { + f.status == DoctorStatus::Error && f.code == Some(OWS_DOCTOR_WALLET_FILE_INVALID) + })); + } + + #[test] + fn test_wallet_files_empty_id() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + let wallet = dummy_wallet("", "Empty ID Wallet"); + let json = serde_json::to_string_pretty(&wallet).unwrap(); + std::fs::write(wallets_dir.join("empty-id.json"), json).ok(); + + let findings = check_wallet_files(&vault); + assert!(findings + .iter() + .any(|f| f.code == Some(OWS_DOCTOR_WALLET_METADATA_CORRUPT))); + } + + #[test] + fn test_wallet_files_invalid_created_at() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + + let bad_wallet_json = serde_json::json!({ + "ows_version": 2, + "id": "test-id", + "name": "Test", + "created_at": "not-a-date", + "accounts": [], + "crypto": {}, + "key_type": "mnemonic" + }); + std::fs::write( + wallets_dir.join("bad-date.json"), + serde_json::to_string_pretty(&bad_wallet_json).unwrap(), + ) + .ok(); + + let findings = check_wallet_files(&vault); + assert!(findings + .iter() + .any(|f| f.code == Some(OWS_DOCTOR_WALLET_METADATA_CORRUPT))); + } + + #[test] + fn test_wallet_files_mixed_valid_and_corrupt() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let wallets_dir = vault.join("wallets"); + std::fs::create_dir_all(&wallets_dir).ok(); + + // Valid wallet + let wallet = dummy_wallet("good", "Good Wallet"); + let json = serde_json::to_string_pretty(&wallet).unwrap(); + std::fs::write(wallets_dir.join("good.json"), json).ok(); + + // Corrupted wallet (malformed JSON) + std::fs::write(wallets_dir.join("bad.json"), "{ bad }").ok(); + + let findings = check_wallet_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Error)); + assert!(findings + .iter() + .any(|f| f.code == Some(OWS_DOCTOR_WALLET_SOME_CORRUPT))); + } + + // ---- Key file tests ---- + + #[test] + fn test_key_files_skipped_when_dir_missing() { + let temp = TempDir::new().unwrap(); + let findings = check_key_files(temp.path()); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].status, DoctorStatus::Skipped); + assert_eq!(findings[0].code, Some(OWS_DOCTOR_DIR_UNREADABLE)); + } + + #[test] + fn test_key_files_empty_dir_is_skipped() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + std::fs::create_dir_all(vault.join("keys")).ok(); + let findings = check_key_files(&vault); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].status, DoctorStatus::Skipped); + assert_eq!(findings[0].code, Some(OWS_DOCTOR_KEY_NONE)); + } + + #[test] + fn test_key_files_one_valid() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let keys_dir = vault.join("keys"); + std::fs::create_dir_all(&keys_dir).ok(); + let key = dummy_key("key-1", "Test Key"); + let json = serde_json::to_string_pretty(&key).unwrap(); + std::fs::write(keys_dir.join("key-1.json"), json).ok(); + + let findings = check_key_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_key_files_malformed_json() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let keys_dir = vault.join("keys"); + std::fs::create_dir_all(&keys_dir).ok(); + std::fs::write(keys_dir.join("bad.json"), "{ invalid }").ok(); + + let findings = check_key_files(&vault); + assert!(findings.iter().any(|f| { + f.status == DoctorStatus::Error && f.code == Some(OWS_DOCTOR_KEY_FILE_INVALID) + })); + } + + // ---- Policy file tests ---- + + #[test] + fn test_policy_files_skipped_when_dir_missing() { + let temp = TempDir::new().unwrap(); + let findings = check_policy_files(temp.path()); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].status, DoctorStatus::Skipped); + assert_eq!(findings[0].code, Some(OWS_DOCTOR_DIR_UNREADABLE)); + } + + #[test] + fn test_policy_files_empty_dir_is_skipped() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + std::fs::create_dir_all(vault.join("policies")).ok(); + let findings = check_policy_files(&vault); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].status, DoctorStatus::Skipped); + assert_eq!(findings[0].code, Some(OWS_DOCTOR_POLICY_NONE)); + } + + #[test] + fn test_policy_files_one_valid() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let policies_dir = vault.join("policies"); + std::fs::create_dir_all(&policies_dir).ok(); + let policy = dummy_policy("pol-1", "Test Policy"); + let json = serde_json::to_string_pretty(&policy).unwrap(); + std::fs::write(policies_dir.join("pol-1.json"), json).ok(); + + let findings = check_policy_files(&vault); + assert!(findings.iter().any(|f| f.status == DoctorStatus::Ok)); + } + + #[test] + fn test_policy_files_malformed_json() { + let temp = TempDir::new().unwrap(); + let vault = temp.path().join(".ows"); + let policies_dir = vault.join("policies"); + std::fs::create_dir_all(&policies_dir).ok(); + std::fs::write(policies_dir.join("bad.json"), "{ invalid }").ok(); + + let findings = check_policy_files(&vault); + assert!(findings.iter().any(|f| { + f.status == DoctorStatus::Error && f.code == Some(OWS_DOCTOR_POLICY_FILE_INVALID) + })); + } +} diff --git a/ows/crates/ows-cli/src/commands/mod.rs b/ows/crates/ows-cli/src/commands/mod.rs index a6fee800..400691d1 100644 --- a/ows/crates/ows-cli/src/commands/mod.rs +++ b/ows/crates/ows-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod derive; +pub mod doctor; pub mod fund; pub mod generate; pub mod info; diff --git a/ows/crates/ows-cli/src/main.rs b/ows/crates/ows-cli/src/main.rs index d106319e..b6454876 100644 --- a/ows/crates/ows-cli/src/main.rs +++ b/ows/crates/ows-cli/src/main.rs @@ -70,6 +70,8 @@ enum Commands { #[arg(long)] purge: bool, }, + /// Run diagnostic checks on the OWS installation + Doctor, } #[derive(Subcommand)] @@ -512,5 +514,6 @@ fn run(cli: Cli) -> Result<(), CliError> { }, Commands::Update { force } => commands::update::run(force), Commands::Uninstall { purge } => commands::uninstall::run(purge), + Commands::Doctor => commands::doctor::run(), } } diff --git a/ows/crates/ows-core/README.md b/ows/crates/ows-core/README.md index dcc8f228..0b8fd671 100644 --- a/ows/crates/ows-core/README.md +++ b/ows/crates/ows-core/README.md @@ -42,6 +42,7 @@ cargo build --workspace --release | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | | `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | ## Language Bindings diff --git a/ows/crates/ows-lib/README.md b/ows/crates/ows-lib/README.md index dcc8f228..0b8fd671 100644 --- a/ows/crates/ows-lib/README.md +++ b/ows/crates/ows-lib/README.md @@ -42,6 +42,7 @@ cargo build --workspace --release | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | | `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | ## Language Bindings diff --git a/ows/crates/ows-signer/README.md b/ows/crates/ows-signer/README.md index dcc8f228..0b8fd671 100644 --- a/ows/crates/ows-signer/README.md +++ b/ows/crates/ows-signer/README.md @@ -42,6 +42,7 @@ cargo build --workspace --release | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | | `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | ## Language Bindings diff --git a/readme/partials/cli-reference.md b/readme/partials/cli-reference.md index d2823da8..39035bed 100644 --- a/readme/partials/cli-reference.md +++ b/readme/partials/cli-reference.md @@ -19,4 +19,5 @@ | `ows key list` | List all API keys | | `ows key revoke` | Revoke an API key | | `ows update` | Update ows and bindings | -| `ows uninstall` | Remove ows from the system | \ No newline at end of file +| `ows uninstall` | Remove ows from the system | +| `ows doctor` | Run diagnostic checks on the OWS installation | \ No newline at end of file