7.3 KB226 lines
Blame
1import { intro, outro, log } from "@clack/prompts";
2import { existsSync, readFileSync } from "node:fs";
3import { join, dirname } from "node:path";
4import { homedir } from "node:os";
5import { execSync } from "node:child_process";
6import { loadConfig } from "../config.js";
7import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js";
8
9interface CheckResult {
10 ok: boolean;
11 message: string;
12 hint?: string;
13 fix?: () => Promise<string | null>;
14}
15
16interface Check {
17 name: string;
18 run: () => Promise<CheckResult>;
19}
20
21const PASS = "\x1b[32m✓\x1b[0m";
22const FAIL = "\x1b[31m✗\x1b[0m";
23const FIX = "\x1b[33m↳\x1b[0m";
24
25function 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
35const 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
192export 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