cli/src/commands/doctor.tsblame
View source
17fe7cd1import { intro, outro, log } from "@clack/prompts";
17fe7cd2import { existsSync, readFileSync } from "node:fs";
17fe7cd3import { join, dirname } from "node:path";
17fe7cd4import { homedir } from "node:os";
17fe7cd5import { execSync } from "node:child_process";
17fe7cd6import { loadConfig } from "../config.js";
17fe7cd7import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js";
17fe7cd8
17fe7cd9interface CheckResult {
17fe7cd10 ok: boolean;
17fe7cd11 message: string;
17fe7cd12 hint?: string;
17fe7cd13 fix?: () => Promise<string | null>;
17fe7cd14}
17fe7cd15
17fe7cd16interface Check {
17fe7cd17 name: string;
17fe7cd18 run: () => Promise<CheckResult>;
17fe7cd19}
17fe7cd20
17fe7cd21const PASS = "\x1b[32m✓\x1b[0m";
17fe7cd22const FAIL = "\x1b[31m✗\x1b[0m";
17fe7cd23const FIX = "\x1b[33m↳\x1b[0m";
17fe7cd24
17fe7cd25function findSlConfig(): string | null {
17fe7cd26 let dir = process.cwd();
17fe7cd27 while (dir !== dirname(dir)) {
17fe7cd28 const p = join(dir, ".sl", "config");
17fe7cd29 if (existsSync(p)) return p;
17fe7cd30 dir = dirname(dir);
17fe7cd31 }
17fe7cd32 return null;
17fe7cd33}
17fe7cd34
17fe7cd35const checks: Check[] = [
17fe7cd36 {
17fe7cd37 name: "Grove config",
17fe7cd38 run: async () => {
17fe7cd39 const configPath = join(homedir(), ".grove", "config.json");
17fe7cd40 if (!existsSync(configPath)) {
17fe7cd41 return { ok: false, message: "~/.grove/config.json not found", hint: "Run: grove auth login" };
17fe7cd42 }
17fe7cd43 const config = await loadConfig();
17fe7cd44 if (!config.token) {
17fe7cd45 return { ok: false, message: "Not authenticated (no token)", hint: "Run: grove auth login" };
17fe7cd46 }
17fe7cd47 return { ok: true, message: `Authenticated with ${config.hub}` };
17fe7cd48 },
17fe7cd49 },
17fe7cd50 {
17fe7cd51 name: "Auth token",
17fe7cd52 run: async () => {
17fe7cd53 const config = await loadConfig();
17fe7cd54 if (!config.token) {
17fe7cd55 return { ok: false, message: "No token", hint: "Run: grove auth login" };
17fe7cd56 }
17fe7cd57 try {
17fe7cd58 const payload = JSON.parse(atob(config.token.split(".")[1]));
17fe7cd59 const exp = payload.exp ? new Date(payload.exp * 1000) : null;
17fe7cd60 if (exp && exp < new Date()) {
17fe7cd61 return { ok: false, message: `Token expired on ${exp.toLocaleDateString()}`, hint: "Run: grove auth login" };
17fe7cd62 }
17fe7cd63 const username = payload.username || payload.sub || "unknown";
17fe7cd64 return { ok: true, message: `Logged in as ${username}${exp ? ` (expires ${exp.toLocaleDateString()})` : ""}` };
17fe7cd65 } catch {
17fe7cd66 return { ok: false, message: "Token is malformed", hint: "Run: grove auth login" };
17fe7cd67 }
17fe7cd68 },
17fe7cd69 },
17fe7cd70 {
17fe7cd71 name: "TLS certificates",
17fe7cd72 run: async () => {
17fe7cd73 const tlsDir = join(homedir(), ".grove", "tls");
17fe7cd74 const files = ["ca.crt", "client.crt", "client.key"];
17fe7cd75 const missing = files.filter((f) => !existsSync(join(tlsDir, f)));
17fe7cd76 if (missing.length > 0) {
17fe7cd77 return { ok: false, message: `Missing: ${missing.join(", ")}`, hint: "Run: grove auth login" };
17fe7cd78 }
17fe7cd79 return { ok: true, message: "All certificates present" };
17fe7cd80 },
17fe7cd81 },
17fe7cd82 {
17fe7cd83 name: "Sapling (sl) installed",
17fe7cd84 run: async () => {
17fe7cd85 const sl = isSaplingInstalled();
17fe7cd86 if (sl.installed && !sl.outdated) {
17fe7cd87 return { ok: true, message: sl.version! };
17fe7cd88 }
17fe7cd89 if (sl.outdated) {
17fe7cd90 return {
17fe7cd91 ok: false,
17fe7cd92 message: `Outdated: ${sl.version}`,
17fe7cd93 fix: async () => {
17fe7cd94 const config = await loadConfig();
17fe7cd95 const result = await downloadAndInstallSapling(config.hub);
17fe7cd96 if (result?.installed) {
17fe7cd97 ensureGroveBinOnPath();
17fe7cd98 return "Installed latest Sapling";
17fe7cd99 }
17fe7cd100 return result?.message || "Could not install";
17fe7cd101 },
17fe7cd102 };
17fe7cd103 }
17fe7cd104 // Not installed at all — check if binary exists but just not on PATH
17fe7cd105 const groveBin = join(homedir(), ".grove", "bin", "sl");
17fe7cd106 const groveSapling = join(homedir(), ".grove", "sapling", "sl");
17fe7cd107 if (existsSync(groveBin) || existsSync(groveSapling)) {
17fe7cd108 return {
17fe7cd109 ok: false,
17fe7cd110 message: "sl installed but not on PATH",
17fe7cd111 fix: async () => {
17fe7cd112 const pathResult = ensureGroveBinOnPath();
17fe7cd113 return pathResult.added ? `Fixed: ${pathResult.message}` : pathResult.message;
17fe7cd114 },
17fe7cd115 };
17fe7cd116 }
17fe7cd117 return {
17fe7cd118 ok: false,
17fe7cd119 message: "sl not found",
17fe7cd120 fix: async () => {
17fe7cd121 const config = await loadConfig();
17fe7cd122 const result = await downloadAndInstallSapling(config.hub);
17fe7cd123 if (result?.installed) {
17fe7cd124 const pathResult = ensureGroveBinOnPath();
17fe7cd125 const msg = "Installed Sapling to ~/.grove/bin/sl";
17fe7cd126 return pathResult.added ? `${msg} — ${pathResult.message}` : msg;
17fe7cd127 }
17fe7cd128 return result?.message || "Could not install";
17fe7cd129 },
17fe7cd130 };
17fe7cd131 },
17fe7cd132 },
17fe7cd133 {
17fe7cd134 name: "Sapling (sl) on PATH",
17fe7cd135 run: async () => {
17fe7cd136 try {
17fe7cd137 const which = execSync("which sl 2>/dev/null", { stdio: "pipe" }).toString().trim();
17fe7cd138 return { ok: true, message: which };
17fe7cd139 } catch {
17fe7cd140 const groveBin = join(homedir(), ".grove", "bin", "sl");
17fe7cd141 if (existsSync(groveBin)) {
17fe7cd142 return {
17fe7cd143 ok: false,
17fe7cd144 message: "sl exists at ~/.grove/bin/sl but is not on PATH",
17fe7cd145 fix: async () => {
17fe7cd146 const result = ensureGroveBinOnPath();
17fe7cd147 return result.added ? `Fixed: ${result.message}` : result.message;
17fe7cd148 },
17fe7cd149 };
17fe7cd150 }
17fe7cd151 return { ok: false, message: "sl not found", hint: "Run: grove auth login" };
17fe7cd152 }
17fe7cd153 },
17fe7cd154 },
17fe7cd155 {
17fe7cd156 name: "Hub reachable",
17fe7cd157 run: async () => {
17fe7cd158 const config = await loadConfig();
17fe7cd159 try {
17fe7cd160 const res = await fetch(`${config.hub}/api/health`, {
17fe7cd161 signal: AbortSignal.timeout(5000),
17fe7cd162 });
17fe7cd163 if (res.ok) {
17fe7cd164 return { ok: true, message: `${config.hub} is reachable` };
17fe7cd165 }
17fe7cd166 return { ok: false, message: `${config.hub} returned ${res.status}` };
17fe7cd167 } catch (err: any) {
17fe7cd168 return { ok: false, message: `Cannot reach ${config.hub}: ${err.message}` };
17fe7cd169 }
17fe7cd170 },
17fe7cd171 },
17fe7cd172 {
17fe7cd173 name: "Repository config",
17fe7cd174 run: async () => {
17fe7cd175 const configPath = findSlConfig();
17fe7cd176 if (!configPath) {
17fe7cd177 return { ok: true, message: "Not inside a Grove repository (ok)" };
17fe7cd178 }
17fe7cd179 const content = readFileSync(configPath, "utf-8");
17fe7cd180 const repoMatch = content.match(/reponame\s*=\s*(.+)/);
17fe7cd181 const ownerMatch = content.match(/owner\s*=\s*(.+)/);
17fe7cd182 if (!repoMatch) {
17fe7cd183 return { ok: false, message: ".sl/config missing reponame", hint: "Re-clone or run: grove init" };
17fe7cd184 }
17fe7cd185 const repo = repoMatch[1].trim();
17fe7cd186 const owner = ownerMatch ? ownerMatch[1].trim() : "unknown";
17fe7cd187 return { ok: true, message: `${owner}/${repo}` };
17fe7cd188 },
17fe7cd189 },
17fe7cd190];
17fe7cd191
17fe7cd192export async function doctor() {
17fe7cd193 intro("grove doctor");
17fe7cd194
17fe7cd195 let failures = 0;
17fe7cd196 let fixed = 0;
17fe7cd197
17fe7cd198 for (const check of checks) {
17fe7cd199 const result = await check.run();
17fe7cd200 if (result.ok) {
17fe7cd201 log.info(`${PASS} ${check.name}: ${result.message}`);
17fe7cd202 } else {
17fe7cd203 failures++;
17fe7cd204 log.info(`${FAIL} ${check.name}: ${result.message}`);
17fe7cd205 if (result.fix) {
17fe7cd206 const fixMsg = await result.fix();
17fe7cd207 if (fixMsg) {
17fe7cd208 log.info(` ${FIX} ${fixMsg}`);
17fe7cd209 fixed++;
17fe7cd210 }
17fe7cd211 } else if (result.hint) {
17fe7cd212 log.info(` ${FIX} ${result.hint}`);
17fe7cd213 }
17fe7cd214 }
17fe7cd215 }
17fe7cd216
17fe7cd217 if (failures === 0) {
17fe7cd218 outro("Everything looks good!");
17fe7cd219 } else if (fixed === failures) {
17fe7cd220 outro(`Fixed ${fixed} issue${fixed > 1 ? "s" : ""} — restart your shell to apply PATH changes`);
17fe7cd221 } else {
17fe7cd222 outro(`Found ${failures} issue${failures > 1 ? "s" : ""}${fixed > 0 ? `, fixed ${fixed}` : ""}`);
17fe7cd223 process.exit(1);
17fe7cd224 }
17fe7cd225}