- 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
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@letterpress-labs/grove-scm", |
| 3 | "version": "0.1.6", | |
| 3 | "version": "0.1.8", | |
| 4 | 4 | "description": "CLI for Grove — self-hosted source control built on Sapling and Mononoke", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "bin": { |
| 7 | 7 | |
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | import { ciLogs } from "./commands/ci-logs.js"; |
| 16 | 16 | import { ciTrigger } from "./commands/ci-trigger.js"; |
| 17 | 17 | import { ciCancel } from "./commands/ci-cancel.js"; |
| 18 | import { doctor } from "./commands/doctor.js"; | |
| 18 | 19 | |
| 19 | 20 | const USAGE = `Usage: grove <command> |
| 20 | 21 | |
| @@ -28,6 +29,7 @@ | ||
| 28 | 29 | repo list List repositories |
| 29 | 30 | repo create Create a repository |
| 30 | 31 | instance create Register a Grove instance |
| 32 | doctor Check your Grove setup for common issues | |
| 31 | 33 | ci runs List pipeline runs |
| 32 | 34 | ci status Show pipeline run details |
| 33 | 35 | ci logs Show step logs |
| @@ -139,6 +141,18 @@ | ||
| 139 | 141 | |
| 140 | 142 | Options: |
| 141 | 143 | --repo <owner/repo> Specify repository (default: inferred from .sl/config)`, |
| 144 | ||
| 145 | doctor: `Usage: grove doctor | |
| 146 | ||
| 147 | Check your Grove setup for common issues. | |
| 148 | ||
| 149 | Checks: | |
| 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)`, | |
| 142 | 156 | }; |
| 143 | 157 | |
| 144 | 158 | function showHelp(command: string): boolean { |
| @@ -230,6 +244,11 @@ | ||
| 230 | 244 | process.exit(1); |
| 231 | 245 | } |
| 232 | 246 | |
| 247 | if (cmd === "doctor") { | |
| 248 | if (wantsHelp(args)) { showHelp("doctor"); process.exit(0); } | |
| 249 | return doctor(); | |
| 250 | } | |
| 251 | ||
| 233 | 252 | if (cmd === "ci") { |
| 234 | 253 | if (wantsHelp(args)) { |
| 235 | 254 | const key = sub && !sub.startsWith("-") ? `ci ${sub}` : undefined; |
| 236 | 255 | |
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | import { execSync } from "node:child_process"; |
| 6 | 6 | import { waitForAuthCallback } from "../auth-server.js"; |
| 7 | 7 | import { loadConfig, saveConfig } from "../config.js"; |
| 8 | import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js"; | |
| 8 | 9 | |
| 9 | 10 | function isHeadless(): boolean { |
| 10 | 11 | // SSH session, no display, or explicitly requested |
| @@ -94,43 +95,22 @@ | ||
| 94 | 95 | } |
| 95 | 96 | |
| 96 | 97 | async 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; | |
| 108 | 100 | |
| 109 | 101 | const s = spinner(); |
| 110 | 102 | s.start("Installing Sapling (sl)"); |
| 111 | 103 | 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)"); | |
| 117 | 107 | return; |
| 118 | 108 | } |
| 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(); | |
| 133 | 110 | s.stop("Sapling (sl) installed"); |
| 111 | if (pathResult.added) { | |
| 112 | log.info(pathResult.message); | |
| 113 | } | |
| 134 | 114 | } catch { |
| 135 | 115 | s.stop("Could not install Sapling (non-fatal)"); |
| 136 | 116 | } |
| 137 | 117 | |
| @@ -0,0 +1,225 @@ | ||
| 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 | } | |
| 0 | 226 | |
| @@ -0,0 +1,95 @@ | ||
| 1 | import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync, unlinkSync, symlinkSync } from "node:fs"; | |
| 2 | import { join } from "node:path"; | |
| 3 | import { homedir, platform, arch } from "node:os"; | |
| 4 | import { 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 | */ | |
| 11 | export 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 | */ | |
| 45 | export 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 | */ | |
| 84 | export 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 | } | |
| 0 | 96 | |