| 1 | import { intro, outro, log } from "@clack/prompts"; |
| 2 | import { existsSync, readFileSync } from "node:fs"; |
| 3 | import { join, dirname } from "node:path"; |
| 4 | import { homedir } from "node:os"; |
| 5 | import { execSync } from "node:child_process"; |
| 6 | import { loadConfig } from "../config.js"; |
| 7 | import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js"; |
| 8 | |
| 9 | interface CheckResult { |
| 10 | ok: boolean; |
| 11 | message: string; |
| 12 | hint?: string; |
| 13 | fix?: () => Promise<string | null>; |
| 14 | } |
| 15 | |
| 16 | interface Check { |
| 17 | name: string; |
| 18 | run: () => Promise<CheckResult>; |
| 19 | } |
| 20 | |
| 21 | const PASS = "\x1b[32m✓\x1b[0m"; |
| 22 | const FAIL = "\x1b[31m✗\x1b[0m"; |
| 23 | const FIX = "\x1b[33m↳\x1b[0m"; |
| 24 | |
| 25 | function findSlConfig(): string | null { |
| 26 | let dir = process.cwd(); |
| 27 | while (dir !== dirname(dir)) { |
| 28 | const p = join(dir, ".sl", "config"); |
| 29 | if (existsSync(p)) return p; |
| 30 | dir = dirname(dir); |
| 31 | } |
| 32 | return null; |
| 33 | } |
| 34 | |
| 35 | const checks: Check[] = [ |
| 36 | { |
| 37 | name: "Grove config", |
| 38 | run: async () => { |
| 39 | const configPath = join(homedir(), ".grove", "config.json"); |
| 40 | if (!existsSync(configPath)) { |
| 41 | return { ok: false, message: "~/.grove/config.json not found", hint: "Run: grove auth login" }; |
| 42 | } |
| 43 | const config = await loadConfig(); |
| 44 | if (!config.token) { |
| 45 | return { ok: false, message: "Not authenticated (no token)", hint: "Run: grove auth login" }; |
| 46 | } |
| 47 | return { ok: true, message: `Authenticated with ${config.hub}` }; |
| 48 | }, |
| 49 | }, |
| 50 | { |
| 51 | name: "Auth token", |
| 52 | run: async () => { |
| 53 | const config = await loadConfig(); |
| 54 | if (!config.token) { |
| 55 | return { ok: false, message: "No token", hint: "Run: grove auth login" }; |
| 56 | } |
| 57 | try { |
| 58 | const payload = JSON.parse(atob(config.token.split(".")[1])); |
| 59 | const exp = payload.exp ? new Date(payload.exp * 1000) : null; |
| 60 | if (exp && exp < new Date()) { |
| 61 | return { ok: false, message: `Token expired on ${exp.toLocaleDateString()}`, hint: "Run: grove auth login" }; |
| 62 | } |
| 63 | const username = payload.username || payload.sub || "unknown"; |
| 64 | return { ok: true, message: `Logged in as ${username}${exp ? ` (expires ${exp.toLocaleDateString()})` : ""}` }; |
| 65 | } catch { |
| 66 | return { ok: false, message: "Token is malformed", hint: "Run: grove auth login" }; |
| 67 | } |
| 68 | }, |
| 69 | }, |
| 70 | { |
| 71 | name: "TLS certificates", |
| 72 | run: async () => { |
| 73 | const tlsDir = join(homedir(), ".grove", "tls"); |
| 74 | const files = ["ca.crt", "client.crt", "client.key"]; |
| 75 | const missing = files.filter((f) => !existsSync(join(tlsDir, f))); |
| 76 | if (missing.length > 0) { |
| 77 | return { ok: false, message: `Missing: ${missing.join(", ")}`, hint: "Run: grove auth login" }; |
| 78 | } |
| 79 | return { ok: true, message: "All certificates present" }; |
| 80 | }, |
| 81 | }, |
| 82 | { |
| 83 | name: "Sapling (sl) installed", |
| 84 | run: async () => { |
| 85 | const sl = isSaplingInstalled(); |
| 86 | if (sl.installed && !sl.outdated) { |
| 87 | return { ok: true, message: sl.version! }; |
| 88 | } |
| 89 | if (sl.outdated) { |
| 90 | return { |
| 91 | ok: false, |
| 92 | message: `Outdated: ${sl.version}`, |
| 93 | fix: async () => { |
| 94 | const config = await loadConfig(); |
| 95 | const result = await downloadAndInstallSapling(config.hub); |
| 96 | if (result?.installed) { |
| 97 | ensureGroveBinOnPath(); |
| 98 | return "Installed latest Sapling"; |
| 99 | } |
| 100 | return result?.message || "Could not install"; |
| 101 | }, |
| 102 | }; |
| 103 | } |
| 104 | // Not installed at all — check if binary exists but just not on PATH |
| 105 | const groveBin = join(homedir(), ".grove", "bin", "sl"); |
| 106 | const groveSapling = join(homedir(), ".grove", "sapling", "sl"); |
| 107 | if (existsSync(groveBin) || existsSync(groveSapling)) { |
| 108 | return { |
| 109 | ok: false, |
| 110 | message: "sl installed but not on PATH", |
| 111 | fix: async () => { |
| 112 | const pathResult = ensureGroveBinOnPath(); |
| 113 | return pathResult.added ? `Fixed: ${pathResult.message}` : pathResult.message; |
| 114 | }, |
| 115 | }; |
| 116 | } |
| 117 | return { |
| 118 | ok: false, |
| 119 | message: "sl not found", |
| 120 | fix: async () => { |
| 121 | const config = await loadConfig(); |
| 122 | const result = await downloadAndInstallSapling(config.hub); |
| 123 | if (result?.installed) { |
| 124 | const pathResult = ensureGroveBinOnPath(); |
| 125 | const msg = "Installed Sapling to ~/.grove/bin/sl"; |
| 126 | return pathResult.added ? `${msg} — ${pathResult.message}` : msg; |
| 127 | } |
| 128 | return result?.message || "Could not install"; |
| 129 | }, |
| 130 | }; |
| 131 | }, |
| 132 | }, |
| 133 | { |
| 134 | name: "Sapling (sl) on PATH", |
| 135 | run: async () => { |
| 136 | try { |
| 137 | const which = execSync("which sl 2>/dev/null", { stdio: "pipe" }).toString().trim(); |
| 138 | return { ok: true, message: which }; |
| 139 | } catch { |
| 140 | const groveBin = join(homedir(), ".grove", "bin", "sl"); |
| 141 | if (existsSync(groveBin)) { |
| 142 | return { |
| 143 | ok: false, |
| 144 | message: "sl exists at ~/.grove/bin/sl but is not on PATH", |
| 145 | fix: async () => { |
| 146 | const result = ensureGroveBinOnPath(); |
| 147 | return result.added ? `Fixed: ${result.message}` : result.message; |
| 148 | }, |
| 149 | }; |
| 150 | } |
| 151 | return { ok: false, message: "sl not found", hint: "Run: grove auth login" }; |
| 152 | } |
| 153 | }, |
| 154 | }, |
| 155 | { |
| 156 | name: "Hub reachable", |
| 157 | run: async () => { |
| 158 | const config = await loadConfig(); |
| 159 | try { |
| 160 | const res = await fetch(`${config.hub}/api/health`, { |
| 161 | signal: AbortSignal.timeout(5000), |
| 162 | }); |
| 163 | if (res.ok) { |
| 164 | return { ok: true, message: `${config.hub} is reachable` }; |
| 165 | } |
| 166 | return { ok: false, message: `${config.hub} returned ${res.status}` }; |
| 167 | } catch (err: any) { |
| 168 | return { ok: false, message: `Cannot reach ${config.hub}: ${err.message}` }; |
| 169 | } |
| 170 | }, |
| 171 | }, |
| 172 | { |
| 173 | name: "Repository config", |
| 174 | run: async () => { |
| 175 | const configPath = findSlConfig(); |
| 176 | if (!configPath) { |
| 177 | return { ok: true, message: "Not inside a Grove repository (ok)" }; |
| 178 | } |
| 179 | const content = readFileSync(configPath, "utf-8"); |
| 180 | const repoMatch = content.match(/reponame\s*=\s*(.+)/); |
| 181 | const ownerMatch = content.match(/owner\s*=\s*(.+)/); |
| 182 | if (!repoMatch) { |
| 183 | return { ok: false, message: ".sl/config missing reponame", hint: "Re-clone or run: grove init" }; |
| 184 | } |
| 185 | const repo = repoMatch[1].trim(); |
| 186 | const owner = ownerMatch ? ownerMatch[1].trim() : "unknown"; |
| 187 | return { ok: true, message: `${owner}/${repo}` }; |
| 188 | }, |
| 189 | }, |
| 190 | ]; |
| 191 | |
| 192 | export async function doctor() { |
| 193 | intro("grove doctor"); |
| 194 | |
| 195 | let failures = 0; |
| 196 | let fixed = 0; |
| 197 | |
| 198 | for (const check of checks) { |
| 199 | const result = await check.run(); |
| 200 | if (result.ok) { |
| 201 | log.info(`${PASS} ${check.name}: ${result.message}`); |
| 202 | } else { |
| 203 | failures++; |
| 204 | log.info(`${FAIL} ${check.name}: ${result.message}`); |
| 205 | if (result.fix) { |
| 206 | const fixMsg = await result.fix(); |
| 207 | if (fixMsg) { |
| 208 | log.info(` ${FIX} ${fixMsg}`); |
| 209 | fixed++; |
| 210 | } |
| 211 | } else if (result.hint) { |
| 212 | log.info(` ${FIX} ${result.hint}`); |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | if (failures === 0) { |
| 218 | outro("Everything looks good!"); |
| 219 | } else if (fixed === failures) { |
| 220 | outro(`Fixed ${fixed} issue${fixed > 1 ? "s" : ""} — restart your shell to apply PATH changes`); |
| 221 | } else { |
| 222 | outro(`Found ${failures} issue${failures > 1 ? "s" : ""}${fixed > 0 ? `, fixed ${fixed}` : ""}`); |
| 223 | process.exit(1); |
| 224 | } |
| 225 | } |
| 226 | |