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