316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
#!/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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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 (
|
|
<Box flexDirection="column">
|
|
<Box>
|
|
{step.status === "running" ? (
|
|
<Text color="cyan">
|
|
<Spinner type="dots" />{" "}
|
|
</Text>
|
|
) : (
|
|
<Text color={color}>{icon} </Text>
|
|
)}
|
|
<Text color={color}>{step.label}</Text>
|
|
{step.detail && <Text color="gray"> — {step.detail}</Text>}
|
|
</Box>
|
|
{step.logs && step.logs.length > 0 && (
|
|
<Box flexDirection="column" marginLeft={3}>
|
|
{step.logs.map((line, i) => (
|
|
<Text key={i} color="gray" dimColor>
|
|
{line}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const [steps, setSteps] = useState<Step[]>([
|
|
{ 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<Step>) => {
|
|
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 (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Box marginBottom={1}>
|
|
<Text bold color="cyan">
|
|
Build & Install Galaxy → /Applications
|
|
</Text>
|
|
</Box>
|
|
|
|
{steps.map((step, i) => (
|
|
<StepLine key={i} step={step} />
|
|
))}
|
|
|
|
{allDone && (
|
|
<Box marginTop={1}>
|
|
{hasError ? (
|
|
<Text color="red" bold>
|
|
✗ Installation failed.
|
|
</Text>
|
|
) : (
|
|
<Text color="green" bold>
|
|
✓ Galaxy installed to /Applications!
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
render(<App />);
|