429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import { spawn } from "child_process";
|
|
import { createHash } from "crypto";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import readline from "readline";
|
|
import semverGt from "semver/functions/gt.js";
|
|
import semverValid from "semver/functions/valid.js";
|
|
|
|
export const GROK_GITHUB_REPO = "superagent-ai/grok-cli";
|
|
export const GROK_RELEASES_API = `https://api.github.com/repos/${GROK_GITHUB_REPO}/releases`;
|
|
export const SCRIPT_INSTALL_METHOD = "script";
|
|
|
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
const INSTALL_SCHEMA_VERSION = 1;
|
|
const PATH_MARKER = "# grok";
|
|
const CONFIG_FILENAMES = ["user-settings.json", "AGENTS.md"];
|
|
const DATA_ENTRIES = ["daemon.pid", "delegations", "grok.db", "models", "schedules"];
|
|
|
|
export interface ReleaseTarget {
|
|
key: "darwin-arm64" | "darwin-x64" | "linux-x64" | "windows-x64";
|
|
assetName: string;
|
|
binaryName: string;
|
|
}
|
|
|
|
export interface ScriptInstallMetadata {
|
|
schemaVersion: number;
|
|
installMethod: typeof SCRIPT_INSTALL_METHOD;
|
|
version: string;
|
|
repo: string;
|
|
binaryPath: string;
|
|
installDir: string;
|
|
assetName: string;
|
|
target: ReleaseTarget["key"];
|
|
installedAt: string;
|
|
shellConfigPath?: string;
|
|
pathCommand?: string;
|
|
}
|
|
|
|
export interface ScriptInstallContext {
|
|
metadata: ScriptInstallMetadata;
|
|
target: ReleaseTarget;
|
|
binaryPath: string;
|
|
}
|
|
|
|
export interface ScriptUpdateRunResult {
|
|
success: boolean;
|
|
output: string;
|
|
}
|
|
|
|
export interface ScriptUninstallOptions {
|
|
dryRun?: boolean;
|
|
force?: boolean;
|
|
keepConfig?: boolean;
|
|
keepData?: boolean;
|
|
}
|
|
|
|
export interface ScriptUninstallPlan {
|
|
removePaths: string[];
|
|
pruneDirs: string[];
|
|
pathCleanup?: { configFile: string; command: string };
|
|
}
|
|
|
|
interface GitHubReleaseAsset {
|
|
name: string;
|
|
browser_download_url: string;
|
|
}
|
|
|
|
interface GitHubRelease {
|
|
tag_name: string;
|
|
assets: GitHubReleaseAsset[];
|
|
}
|
|
|
|
interface ReleaseDownload {
|
|
version: string;
|
|
asset: GitHubReleaseAsset;
|
|
checksums: GitHubReleaseAsset;
|
|
}
|
|
|
|
export function getGrokUserDir(homeDir = os.homedir()): string {
|
|
return path.join(homeDir, ".grok");
|
|
}
|
|
|
|
export function getScriptInstallDir(homeDir = os.homedir()): string {
|
|
return path.join(getGrokUserDir(homeDir), "bin");
|
|
}
|
|
|
|
export function getInstallMetadataPath(homeDir = os.homedir()): string {
|
|
return path.join(getGrokUserDir(homeDir), "install.json");
|
|
}
|
|
|
|
export function getReleaseTargetForPlatform(platform = process.platform, arch = process.arch): ReleaseTarget | null {
|
|
if (platform === "darwin" && arch === "arm64")
|
|
return { key: "darwin-arm64", assetName: "grok-darwin-arm64", binaryName: "grok" };
|
|
if (platform === "darwin" && arch === "x64")
|
|
return { key: "darwin-x64", assetName: "grok-darwin-x64", binaryName: "grok" };
|
|
if (platform === "linux" && arch === "x64")
|
|
return { key: "linux-x64", assetName: "grok-linux-x64", binaryName: "grok" };
|
|
if (platform === "win32" && arch === "x64")
|
|
return { key: "windows-x64", assetName: "grok-windows-x64.exe", binaryName: "grok.exe" };
|
|
return null;
|
|
}
|
|
|
|
export function loadScriptInstallMetadata(homeDir = os.homedir()): ScriptInstallMetadata | null {
|
|
const metadataPath = getInstallMetadataPath(homeDir);
|
|
try {
|
|
if (!fs.existsSync(metadataPath)) return null;
|
|
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Partial<ScriptInstallMetadata>;
|
|
if (parsed.installMethod !== SCRIPT_INSTALL_METHOD) return null;
|
|
if (
|
|
typeof parsed.binaryPath !== "string" ||
|
|
typeof parsed.installDir !== "string" ||
|
|
typeof parsed.assetName !== "string" ||
|
|
typeof parsed.target !== "string"
|
|
)
|
|
return null;
|
|
return {
|
|
schemaVersion: INSTALL_SCHEMA_VERSION,
|
|
installMethod: SCRIPT_INSTALL_METHOD,
|
|
version: typeof parsed.version === "string" ? parsed.version : "unknown",
|
|
repo: typeof parsed.repo === "string" ? parsed.repo : GROK_GITHUB_REPO,
|
|
binaryPath: parsed.binaryPath,
|
|
installDir: parsed.installDir,
|
|
assetName: parsed.assetName,
|
|
target: parsed.target as ReleaseTarget["key"],
|
|
installedAt: typeof parsed.installedAt === "string" ? parsed.installedAt : new Date(0).toISOString(),
|
|
shellConfigPath: typeof parsed.shellConfigPath === "string" ? parsed.shellConfigPath : undefined,
|
|
pathCommand: typeof parsed.pathCommand === "string" ? parsed.pathCommand : undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function saveScriptInstallMetadata(metadata: ScriptInstallMetadata, homeDir = os.homedir()): void {
|
|
const metadataPath = getInstallMetadataPath(homeDir);
|
|
fs.mkdirSync(path.dirname(metadataPath), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
}
|
|
|
|
export function getScriptInstallContext(homeDir = os.homedir()): ScriptInstallContext | null {
|
|
const target = getReleaseTargetForPlatform();
|
|
if (!target) return null;
|
|
|
|
const metadata = loadScriptInstallMetadata(homeDir);
|
|
if (metadata) {
|
|
return {
|
|
metadata,
|
|
target: getReleaseTargetForPlatformKey(metadata.target) ?? target,
|
|
binaryPath: metadata.binaryPath,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function fetchLatestReleaseVersion(): Promise<string | null> {
|
|
const release = await fetchReleaseJson(`${GROK_RELEASES_API}/latest`);
|
|
return release ? normalizeReleaseVersion(release.tag_name) : null;
|
|
}
|
|
|
|
export function parseChecksumsFile(contents: string): Map<string, string> {
|
|
const result = new Map<string, string>();
|
|
for (const rawLine of contents.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
const match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
if (!match) continue;
|
|
result.set(match[2], match[1].toLowerCase());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function runScriptManagedUpdate(currentVersion: string): Promise<ScriptUpdateRunResult> {
|
|
const context = getScriptInstallContext();
|
|
if (!context) return notScriptManaged("update");
|
|
|
|
const normalizedCurrent = semverValid(currentVersion);
|
|
if (!normalizedCurrent) {
|
|
return { success: false, output: `Cannot update: current version "${currentVersion}" is invalid.` };
|
|
}
|
|
|
|
const release = await resolveReleaseDownload(context.target);
|
|
if (!release) {
|
|
return { success: false, output: "No matching release found for this platform." };
|
|
}
|
|
|
|
if (!semverGt(release.version, normalizedCurrent)) {
|
|
return { success: true, output: `Already on the latest version (${normalizedCurrent}).` };
|
|
}
|
|
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "grok-update-"));
|
|
try {
|
|
const downloadedPath = path.join(tempDir, release.asset.name);
|
|
await downloadBinary(release.asset.browser_download_url, downloadedPath);
|
|
|
|
const checksumsText = await downloadText(release.checksums.browser_download_url);
|
|
const expectedHash = parseChecksumsFile(checksumsText).get(release.asset.name);
|
|
if (!expectedHash) return { success: false, output: `Missing checksum for ${release.asset.name}.` };
|
|
|
|
if (sha256File(downloadedPath) !== expectedHash) {
|
|
return { success: false, output: `Checksum mismatch for ${release.asset.name}; aborting.` };
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(context.binaryPath), { recursive: true, mode: 0o700 });
|
|
|
|
if (process.platform === "win32") {
|
|
return applyWindowsUpdate(tempDir, downloadedPath, context, release);
|
|
}
|
|
|
|
const staging = `${context.binaryPath}.new`;
|
|
fs.copyFileSync(downloadedPath, staging);
|
|
fs.chmodSync(staging, 0o755);
|
|
fs.renameSync(staging, context.binaryPath);
|
|
|
|
saveScriptInstallMetadata({
|
|
...context.metadata,
|
|
version: release.version,
|
|
installedAt: new Date().toISOString(),
|
|
});
|
|
|
|
return { success: true, output: `Updated to Grok ${release.version}.` };
|
|
} catch (error) {
|
|
return { success: false, output: error instanceof Error ? error.message : String(error) };
|
|
} finally {
|
|
if (process.platform !== "win32") fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export function buildScriptUninstallPlan(
|
|
options: ScriptUninstallOptions = {},
|
|
homeDir = os.homedir(),
|
|
): ScriptUninstallPlan | null {
|
|
const context = getScriptInstallContext(homeDir);
|
|
if (!context) return null;
|
|
|
|
const userDir = getGrokUserDir(homeDir);
|
|
const removePaths = new Set<string>();
|
|
const pruneDirs = new Set<string>();
|
|
|
|
if (!options.keepConfig && !options.keepData) {
|
|
removePaths.add(userDir);
|
|
} else {
|
|
removePaths.add(context.binaryPath);
|
|
removePaths.add(getInstallMetadataPath(homeDir));
|
|
if (!options.keepConfig) for (const f of CONFIG_FILENAMES) removePaths.add(path.join(userDir, f));
|
|
if (!options.keepData) for (const e of DATA_ENTRIES) removePaths.add(path.join(userDir, e));
|
|
pruneDirs.add(getScriptInstallDir(homeDir));
|
|
pruneDirs.add(userDir);
|
|
}
|
|
|
|
return {
|
|
removePaths: sortForRemoval([...removePaths]),
|
|
pruneDirs: sortForRemoval([...pruneDirs]),
|
|
pathCleanup:
|
|
context.metadata.shellConfigPath && context.metadata.pathCommand
|
|
? { configFile: context.metadata.shellConfigPath, command: context.metadata.pathCommand }
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
export async function runScriptManagedUninstall(options: ScriptUninstallOptions = {}): Promise<ScriptUpdateRunResult> {
|
|
const plan = buildScriptUninstallPlan(options);
|
|
if (!plan) return notScriptManaged("uninstall");
|
|
|
|
if (options.dryRun) return { success: true, output: formatDryRun(plan, options) };
|
|
|
|
if (!options.force) {
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
return { success: false, output: "Non-interactive terminal. Re-run with --force." };
|
|
}
|
|
if (!(await confirm("Remove Grok from this machine?"))) {
|
|
return { success: false, output: "Uninstall cancelled." };
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (plan.pathCleanup) removePathLine(plan.pathCleanup.configFile, plan.pathCleanup.command);
|
|
for (const p of plan.removePaths) fs.rmSync(p, { recursive: true, force: true });
|
|
for (const d of plan.pruneDirs) removeDirIfEmpty(d);
|
|
return { success: true, output: "Grok uninstall complete." };
|
|
} catch (error) {
|
|
return { success: false, output: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
function notScriptManaged(action: string): ScriptUpdateRunResult {
|
|
return {
|
|
success: false,
|
|
output: `This install is not script-managed, so \`grok ${action}\` cannot proceed. Use the package manager you installed with, or reinstall via install.sh.`,
|
|
};
|
|
}
|
|
|
|
function getReleaseTargetForPlatformKey(key: string): ReleaseTarget | null {
|
|
switch (key) {
|
|
case "darwin-arm64":
|
|
return { key, assetName: "grok-darwin-arm64", binaryName: "grok" };
|
|
case "darwin-x64":
|
|
return { key, assetName: "grok-darwin-x64", binaryName: "grok" };
|
|
case "linux-x64":
|
|
return { key, assetName: "grok-linux-x64", binaryName: "grok" };
|
|
case "windows-x64":
|
|
return { key, assetName: "grok-windows-x64.exe", binaryName: "grok.exe" };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveReleaseDownload(target: ReleaseTarget): Promise<ReleaseDownload | null> {
|
|
const release = await fetchReleaseJson(`${GROK_RELEASES_API}/latest`);
|
|
if (!release) return null;
|
|
const version = normalizeReleaseVersion(release.tag_name);
|
|
if (!version) return null;
|
|
|
|
const asset = release.assets.find((a) => a.name === target.assetName);
|
|
const checksums = release.assets.find((a) => a.name === "checksums.txt");
|
|
if (!asset || !checksums) return null;
|
|
|
|
return { version, asset, checksums };
|
|
}
|
|
|
|
async function fetchReleaseJson(url: string): Promise<GitHubRelease | null> {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
const res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/vnd.github+json" } });
|
|
clearTimeout(timer);
|
|
return res.ok ? ((await res.json()) as GitHubRelease) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeReleaseVersion(tagName: string): string | null {
|
|
let version = tagName;
|
|
if (version.startsWith("grok-dev@")) version = version.slice("grok-dev@".length);
|
|
if (version.startsWith("v")) version = version.slice(1);
|
|
return semverValid(version);
|
|
}
|
|
|
|
async function downloadBinary(url: string, dest: string): Promise<void> {
|
|
const res = await fetch(url, { headers: { Accept: "application/octet-stream" } });
|
|
if (!res.ok) throw new Error(`Download failed (${res.status}) for ${url}`);
|
|
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
|
}
|
|
|
|
async function downloadText(url: string): Promise<string> {
|
|
const res = await fetch(url, { headers: { Accept: "text/plain" } });
|
|
if (!res.ok) throw new Error(`Download failed (${res.status}) for ${url}`);
|
|
return await res.text();
|
|
}
|
|
|
|
function sha256File(filePath: string): string {
|
|
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
}
|
|
|
|
function sortForRemoval(paths: string[]): string[] {
|
|
return [...new Set(paths)].sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
function removeDirIfEmpty(dir: string): void {
|
|
try {
|
|
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
function removePathLine(configFile: string, command: string): void {
|
|
if (!fs.existsSync(configFile)) return;
|
|
const lines = fs.readFileSync(configFile, "utf8").split(/\r?\n/);
|
|
fs.writeFileSync(
|
|
configFile,
|
|
`${lines
|
|
.filter((l) => l !== PATH_MARKER && l !== command)
|
|
.join("\n")
|
|
.replace(/\n+$/, "")}\n`,
|
|
);
|
|
}
|
|
|
|
function formatDryRun(plan: ScriptUninstallPlan, options: ScriptUninstallOptions): string {
|
|
const lines = ["Dry run — would perform:"];
|
|
if (plan.pathCleanup) lines.push(` remove PATH entry from ${plan.pathCleanup.configFile}`);
|
|
for (const p of plan.removePaths) lines.push(` remove ${p}`);
|
|
if (options.keepConfig) lines.push(" keep config files");
|
|
if (options.keepData) lines.push(" keep data files");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
async function confirm(prompt: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
rl.question(`${prompt} [y/N] `, (answer) => {
|
|
rl.close();
|
|
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
});
|
|
});
|
|
}
|
|
|
|
function applyWindowsUpdate(
|
|
tempDir: string,
|
|
downloadedPath: string,
|
|
context: ScriptInstallContext,
|
|
release: ReleaseDownload,
|
|
): ScriptUpdateRunResult {
|
|
const script = [
|
|
"$ErrorActionPreference = 'Stop'",
|
|
"Start-Sleep -Seconds 2",
|
|
`Move-Item -LiteralPath '${esc(downloadedPath)}' -Destination '${esc(context.binaryPath)}' -Force`,
|
|
].join("\n");
|
|
|
|
const scriptPath = path.join(tempDir, "apply-update.ps1");
|
|
fs.writeFileSync(scriptPath, script);
|
|
|
|
saveScriptInstallMetadata({ ...context.metadata, version: release.version, installedAt: new Date().toISOString() });
|
|
|
|
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
child.unref();
|
|
|
|
return { success: true, output: `Updated to Grok ${release.version}. Restart the CLI to use the new version.` };
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/'/g, "''");
|
|
}
|