#!/usr/bin/env tsx import React, { useState, useEffect, useCallback } from "react"; import { render, Text, Box } from "ink"; import Spinner from "ink-spinner"; import { spawn, execFileSync, execSync } from "child_process"; import path from "path"; import fs from "fs"; // ─── Configuration ─────────────────────────────────────────────────────────── // Workspace root is three levels up from script/build-and-install-to-applications/src const WORKSPACE_ROOT = path.resolve(import.meta.dirname, "../../.."); // ─── Types ─────────────────────────────────────────────────────────────────── type StepStatus = "pending" | "running" | "done" | "error"; interface Step { label: string; status: StepStatus; detail?: string; logs?: string[]; } const MAX_LOG_LINES = 15; function runCommandStreaming( cmd: string, cwd: string, onLog: (line: string) => void, interactive = false ): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd, { cwd, shell: true, stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"], }); const createChunkProcessor = () => { let leftover = ""; return { process(chunk: Buffer) { const text = leftover + chunk.toString(); // Cargo renders progress with carriage returns when attached to a terminal. Treat both // carriage returns and newlines as line boundaries so build output appears live in Ink. const lines = text.split(/\r\n|\r|\n/); leftover = lines.pop() || ""; for (const line of lines) { if (line.trim()) onLog(line); } }, flush() { if (leftover.trim()) onLog(leftover); leftover = ""; }, }; }; const stdout = createChunkProcessor(); const stderr = createChunkProcessor(); child.stdout?.on("data", (chunk: Buffer) => stdout.process(chunk)); child.stderr?.on("data", (chunk: Buffer) => stderr.process(chunk)); child.on("close", (code) => { stdout.flush(); stderr.flush(); if (code === 0) resolve(); else reject(new Error(`Command failed with exit code ${code}`)); }); child.on("error", reject); }); } function isAppRunning(processName: string): boolean { try { execFileSync("pgrep", ["-x", processName], { stdio: "ignore" }); return true; } catch { return false; } } async function waitForAppToExit(processName: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!isAppRunning(processName)) return true; await new Promise((resolve) => setTimeout(resolve, 100)); } return !isAppRunning(processName); } async function stopApp(processName: string, onLog: (line: string) => void): Promise { if (!isAppRunning(processName)) return; onLog(`${processName} is running. Stopping it before replacing the app...`); try { execFileSync("pkill", ["-TERM", "-x", processName], { stdio: "ignore" }); } catch { // The process may exit between the running check and the signal. } if (await waitForAppToExit(processName, 2000)) return; onLog(`${processName} did not exit after SIGTERM. Force killing it...`); try { execFileSync("pkill", ["-KILL", "-x", processName], { stdio: "ignore" }); } catch { // Verify the process state below instead of relying on pkill's exit status. } if (!(await waitForAppToExit(processName, 1000))) { throw new Error(`Unable to stop ${processName} before updating /Applications`); } } // ─── UI Component ──────────────────────────────────────────────────────────── function StepLine({ step }: { step: Step }) { const icon = step.status === "pending" ? "○" : step.status === "running" ? "" : step.status === "done" ? "✓" : "✗"; const color = step.status === "pending" ? "gray" : step.status === "running" ? "cyan" : step.status === "done" ? "green" : "red"; return ( {step.status === "running" ? ( {" "} ) : ( {icon} )} {step.label} {step.detail && — {step.detail}} {step.logs && step.logs.length > 0 && ( {step.logs.map((line, i) => ( {line} ))} )} ); } function App() { const [steps, setSteps] = useState([ { label: "Check working directory", status: "pending" }, { label: "Pull current branch", status: "pending" }, { label: "Bundle Galaxy", status: "pending" }, { label: "Copy to /Applications", status: "pending" }, { label: "Launch Galaxy", status: "pending" }, ]); const updateStep = useCallback((index: number, update: Partial) => { setSteps((prev) => prev.map((s, i) => (i === index ? { ...s, ...update } : s))); }, []); const appendLog = useCallback((index: number, line: string) => { setSteps((prev) => prev.map((s, i) => { if (i !== index) return s; const logs = [...(s.logs || []), line].slice(-MAX_LOG_LINES); return { ...s, logs }; }) ); }, []); useEffect(() => { (async () => { try { const appName = "Galaxy.app"; const bundleDir = path.join(WORKSPACE_ROOT, "target/release/bundle/osx"); const appPath = path.join(bundleDir, appName); const destPath = path.join("/Applications", appName); // ─── Step 0: Check working directory ─────────────────────────── updateStep(0, { status: "running" }); const changes = execFileSync("git", ["status", "--short"], { cwd: WORKSPACE_ROOT, encoding: "utf8", }).trimEnd(); if (changes) { for (const line of changes.split("\n")) appendLog(0, line); throw new Error("Working directory has uncommitted changes. Commit or stash them before installing."); } updateStep(0, { status: "done" }); // ─── Step 1: Pull current branch ──────────────────────────────── updateStep(1, { status: "running" }); const branch = execSync("git branch --show-current", { cwd: WORKSPACE_ROOT, encoding: "utf8", }).trim(); if (!branch) throw new Error("Cannot install from a detached HEAD"); await runCommandStreaming( `git pull --ff-only origin "${branch}"`, WORKSPACE_ROOT, (line) => appendLog(1, line) ); updateStep(1, { status: "done", detail: branch }); // ─── Step 2: Bundle ───────────────────────────────────────────── updateStep(2, { status: "running" }); await runCommandStreaming( "cargo bundle --release --bin galaxy-oss --package galaxy --features release_bundle,extern_plist", WORKSPACE_ROOT, (line) => appendLog(2, line) ); updateStep(2, { status: "done" }); // ─── Step 3: Copy to /Applications ───────────────────────────── updateStep(3, { status: "running" }); if (!fs.existsSync(appPath)) { throw new Error(`Built app not found at ${appPath}`); } await stopApp("Galaxy", (line) => appendLog(3, line)); appendLog(3, "Administrator password required to install in /Applications"); await runCommandStreaming( "sudo -k && sudo -v", WORKSPACE_ROOT, (line) => appendLog(3, line), true ); if (fs.existsSync(destPath)) { appendLog(3, `Removing existing ${destPath}`); await runCommandStreaming( `sudo -n rm -rf "${destPath}"`, WORKSPACE_ROOT, (line) => appendLog(3, line) ); } appendLog(3, `Copying ${appPath} → ${destPath}`); await runCommandStreaming( `sudo -n ditto "${appPath}" "${destPath}"`, WORKSPACE_ROOT, (line) => appendLog(3, line) ); updateStep(3, { status: "done", detail: destPath }); // ─── Step 4: Launch ──────────────────────────────────────────── updateStep(4, { status: "running" }); execSync(`open "${destPath}"`, { stdio: "ignore" }); updateStep(4, { status: "done" }); } catch (err: any) { process.exitCode = 1; setSteps((prev) => prev.map((s) => s.status === "running" ? { ...s, status: "error" as StepStatus, detail: err.message } : s ) ); } })(); }, []); const hasError = steps.some((s) => s.status === "error"); const allDone = hasError || steps.every((s) => s.status === "done"); return ( Build & Install Galaxy → /Applications {steps.map((step, i) => ( ))} {allDone && ( {hasError ? ( ✗ Installation failed. ) : ( ✓ Galaxy installed to /Applications! )} )} ); } render();