Add grove doctor command and fix macOS Sapling install

- Add `grove doctor` command that checks config, auth, TLS certs,
  Sapling installation, PATH, hub reachability, and repo config
- Doctor auto-fixes issues: downloads sl binary and adds ~/.grove/bin
  to PATH when sl is missing
- Extract shared install-sapling.ts used by both auth-login and doctor
- Install sl to ~/.grove/bin instead of /usr/local (no sudo needed)
- Detect actual shell (zsh/bash) for correct rc file
- Upload sl-darwin-arm64.tar.gz to grove.host downloads
- Bump to 0.1.8
Anton Kaminsky10d ago17fe7cdb6847parent 7be5e0c
5 files changed+350-31
cli/package.json
@@ -1,6 +1,6 @@
11{
22 "name": "@letterpress-labs/grove-scm",
3 "version": "0.1.6",
3 "version": "0.1.8",
44 "description": "CLI for Grove — self-hosted source control built on Sapling and Mononoke",
55 "type": "module",
66 "bin": {
77
cli/src/cli.ts
@@ -15,6 +15,7 @@
1515import { ciLogs } from "./commands/ci-logs.js";
1616import { ciTrigger } from "./commands/ci-trigger.js";
1717import { ciCancel } from "./commands/ci-cancel.js";
18import { doctor } from "./commands/doctor.js";
1819
1920const USAGE = `Usage: grove <command>
2021
@@ -28,6 +29,7 @@
2829 repo list List repositories
2930 repo create Create a repository
3031 instance create Register a Grove instance
32 doctor Check your Grove setup for common issues
3133 ci runs List pipeline runs
3234 ci status Show pipeline run details
3335 ci logs Show step logs
@@ -139,6 +141,18 @@
139141
140142Options:
141143 --repo <owner/repo> Specify repository (default: inferred from .sl/config)`,
144
145 doctor: `Usage: grove doctor
146
147Check your Grove setup for common issues.
148
149Checks:
150 - Grove config and authentication
151 - Auth token validity
152 - TLS certificates
153 - Sapling (sl) installation and PATH
154 - Hub reachability
155 - Repository config (if inside a repo)`,
142156};
143157
144158function showHelp(command: string): boolean {
@@ -230,6 +244,11 @@
230244 process.exit(1);
231245 }
232246
247 if (cmd === "doctor") {
248 if (wantsHelp(args)) { showHelp("doctor"); process.exit(0); }
249 return doctor();
250 }
251
233252 if (cmd === "ci") {
234253 if (wantsHelp(args)) {
235254 const key = sub && !sub.startsWith("-") ? `ci ${sub}` : undefined;
236255
cli/src/commands/auth-login.ts
@@ -5,6 +5,7 @@
55import { execSync } from "node:child_process";
66import { waitForAuthCallback } from "../auth-server.js";
77import { loadConfig, saveConfig } from "../config.js";
8import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js";
89
910function isHeadless(): boolean {
1011 // SSH session, no display, or explicitly requested
@@ -94,43 +95,22 @@
9495}
9596
9697async function installSapling(hub: string) {
97 const os = platform();
98 if (os !== "linux" && os !== "darwin") return;
99
100 // Check if sl is already installed and working
101 try {
102 const version = execSync("sl --version 2>&1", { stdio: "pipe" }).toString();
103 // Our builds are version 4.x+, old official release is 0.2
104 if (!version.includes("0.2.")) return;
105 } catch {
106 // sl not found — need to install
107 }
98 const sl = isSaplingInstalled();
99 if (sl.installed && !sl.outdated) return;
108100
109101 const s = spinner();
110102 s.start("Installing Sapling (sl)");
111103 try {
112 const archName = arch() === "x64" ? "x86_64" : arch();
113 const tarball = `sl-${os}-${archName}.tar.gz`;
114 const res = await fetch(`${hub}/downloads/${tarball}`);
115 if (!res.ok) {
116 s.stop("Sapling binary not available yet (non-fatal)");
104 const result = await downloadAndInstallSapling(hub);
105 if (!result || !result.installed) {
106 s.stop(result?.message || "Sapling binary not available yet (non-fatal)");
117107 return;
118108 }
119 const buf = Buffer.from(await res.arrayBuffer());
120 const installDir = "/usr/local/lib/sapling";
121 const symlink = "/usr/local/bin/sl";
122
123 // Write tarball to temp file and extract
124 const tmpTar = "/tmp/sl-install.tar.gz";
125 writeFileSync(tmpTar, buf);
126 execSync(`rm -rf ${installDir} && mkdir -p ${installDir} && tar xzf ${tmpTar} -C ${installDir}`, { stdio: "pipe" });
127 execSync(`rm -f ${tmpTar}`, { stdio: "pipe" });
128
129 // Create symlink at /usr/local/bin/sl -> wrapper script
130 try { unlinkSync(symlink); } catch {}
131 symlinkSync(join(installDir, "sl"), symlink);
132
109 const pathResult = ensureGroveBinOnPath();
133110 s.stop("Sapling (sl) installed");
111 if (pathResult.added) {
112 log.info(pathResult.message);
113 }
134114 } catch {
135115 s.stop("Could not install Sapling (non-fatal)");
136116 }
137117
cli/src/commands/doctor.ts
@@ -0,0 +1,225 @@
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}
0226
cli/src/install-sapling.ts
@@ -0,0 +1,95 @@
1import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync, unlinkSync, symlinkSync } from "node:fs";
2import { join } from "node:path";
3import { homedir, platform, arch } from "node:os";
4import { execSync } from "node:child_process";
5
6/**
7 * Download and install the Sapling (sl) binary to ~/.grove/sapling,
8 * symlink to ~/.grove/bin/sl, and ensure ~/.grove/bin is on PATH.
9 * Returns a status message or null if no install was needed.
10 */
11export async function downloadAndInstallSapling(hub: string): Promise<{ installed: boolean; message: string } | null> {
12 const os = platform();
13 if (os !== "linux" && os !== "darwin") return null;
14
15 const archName = arch() === "x64" ? "x86_64" : arch();
16 const tarball = `sl-${os}-${archName}.tar.gz`;
17 const res = await fetch(`${hub}/downloads/${tarball}`);
18 if (!res.ok) {
19 return { installed: false, message: "Sapling binary not available for this platform yet" };
20 }
21 const buf = Buffer.from(await res.arrayBuffer());
22
23 const groveDir = join(homedir(), ".grove");
24 const installDir = join(groveDir, "sapling");
25 const binDir = join(groveDir, "bin");
26
27 const tmpTar = join(groveDir, "sl-install.tar.gz");
28 mkdirSync(installDir, { recursive: true });
29 mkdirSync(binDir, { recursive: true });
30 writeFileSync(tmpTar, buf);
31 execSync(`rm -rf ${installDir} && mkdir -p ${installDir} && tar xzf ${tmpTar} -C ${installDir}`, { stdio: "pipe" });
32 unlinkSync(tmpTar);
33
34 const symlink = join(binDir, "sl");
35 try { unlinkSync(symlink); } catch {}
36 symlinkSync(join(installDir, "sl"), symlink);
37
38 return { installed: true, message: "Sapling (sl) installed to ~/.grove/bin/sl" };
39}
40
41/**
42 * Ensure ~/.grove/bin is in the user's shell PATH.
43 * Returns a message about what was done.
44 */
45export function ensureGroveBinOnPath(): { added: boolean; message: string } {
46 const os = platform();
47 const binDir = join(homedir(), ".grove", "bin");
48 const pathDirs = (process.env.PATH || "").split(":");
49
50 if (pathDirs.includes(binDir)) {
51 return { added: false, message: "~/.grove/bin already on PATH" };
52 }
53
54 // Detect the user's actual shell to pick the right rc file
55 const shell = process.env.SHELL || "";
56 let rcName: string;
57 if (shell.endsWith("/zsh")) {
58 rcName = ".zshrc";
59 } else if (shell.endsWith("/bash")) {
60 rcName = ".bash_profile";
61 } else if (shell.endsWith("/fish")) {
62 // fish uses a different syntax, but PATH export still works via conf.d
63 rcName = ".bash_profile";
64 } else {
65 rcName = os === "linux" ? ".bashrc" : ".zshrc";
66 }
67 const rcFile = join(homedir(), rcName);
68 const exportLine = `export PATH="$HOME/.grove/bin:$PATH"`;
69
70 try {
71 const existing = existsSync(rcFile) ? readFileSync(rcFile, "utf-8") : "";
72 if (!existing.includes(".grove/bin")) {
73 appendFileSync(rcFile, `\n# Added by Grove\n${exportLine}\n`);
74 }
75 return { added: true, message: `Added to ~/${rcName} — restart your shell or run: source ~/${rcName}` };
76 } catch {
77 return { added: false, message: `Could not write to ~/${rcName}` };
78 }
79}
80
81/**
82 * Check if sl is already installed and up-to-date.
83 */
84export function isSaplingInstalled(): { installed: boolean; outdated: boolean; version?: string } {
85 try {
86 const version = execSync("sl --version 2>&1", { stdio: "pipe" }).toString().trim();
87 const firstLine = version.split("\n")[0];
88 if (firstLine.includes("0.2.")) {
89 return { installed: true, outdated: true, version: firstLine };
90 }
91 return { installed: true, outdated: false, version: firstLine };
92 } catch {
93 return { installed: false, outdated: false };
94 }
95}
096