| 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 | } |
| 96 | |