#!/usr/bin/env tsx import React, { useState, useEffect, useCallback } from "react"; import { render, Text, Box } from "ink"; import Spinner from "ink-spinner"; import { spawn, execSync } from "child_process"; import { statSync, readFileSync } from "fs"; import { createHash } from "crypto"; import path from "path"; // ─── Configuration ─────────────────────────────────────────────────────────── const HERMES_USER = process.env.HERMES_USER ?? "ryan"; const HERMES_PASS = process.env.HERMES_PASS; if (!HERMES_PASS) { console.error("Error: HERMES_PASS environment variable is required."); process.exit(1); } const BASE_URL = "https://client.wst.mini-games.tv"; const UPLOAD_KEY = "wst-data/ryan-share/galaxy/Galaxy.zip"; const INSTALL_SCRIPT_KEY = "wst-data/ryan-share/galaxy/install-galaxy.sh"; const CONTENT_TYPE = "application/zip"; const SCRIPT_CONTENT_TYPE = "text/x-shellscript"; // Workspace root is three levels up from script/build-and-deploy-hermes/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[]; progress?: { bytes: number; totalBytes: number }; } const MAX_LOG_LINES = 15; function runCommandStreaming( cmd: string, cwd: string, onLog: (line: string) => void ): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"], }); let leftover = ""; const processChunk = (chunk: Buffer) => { const text = leftover + chunk.toString(); const lines = text.split("\n"); leftover = lines.pop() || ""; for (const line of lines) { if (line.trim()) onLog(line); } }; child.stdout?.on("data", processChunk); child.stderr?.on("data", processChunk); child.on("close", (code) => { if (leftover.trim()) onLog(leftover); if (code === 0) resolve(); else reject(new Error(`Command failed with exit code ${code}`)); }); child.on("error", reject); }); } interface StartUploadResponse { uploadId: string; key: string; totalParts: number; partSize: number; urls: { partNumber: number; url: string }[]; } // ─── Helpers ───────────────────────────────────────────────────────────────── const COMMON_HEADERS: Record = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", Accept: "*/*", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", Origin: BASE_URL, "Sec-GPC": "1", Connection: "keep-alive", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "Pragma": "no-cache", "Cache-Control": "no-cache", }; function authHeaders(token: string): Record { return { ...COMMON_HEADERS, Authorization: `Bearer ${token}` }; } async function apiPost(url: string, body: object, headers: Record): Promise { const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(`POST ${url} failed (${res.status}): ${text}`); } return res.json() as Promise; } function computeFileHash(filePath: string): string { const hash = createHash("sha256"); const data = readFileSync(filePath); hash.update(data); return hash.digest("hex"); } async function uploadPart( url: string, filePath: string, partNumber: number, partSize: number, totalParts: number, onProgress: (bytesSent: number) => void ): Promise { const fileSize = statSync(filePath).size; const start = (partNumber - 1) * partSize; const end = partNumber === totalParts ? fileSize : start + partSize; const length = end - start; // Read the chunk into a buffer const { openSync, readSync, closeSync } = await import("fs"); const fd = openSync(filePath, "r"); const buffer = Buffer.alloc(length); readSync(fd, buffer, 0, length, start); closeSync(fd); // Stream the upload to track progress const CHUNK_SIZE = 256 * 1024; // 256KB reporting chunks let uploaded = 0; const stream = new ReadableStream({ start(controller) { let offset = 0; function push() { if (offset >= length) { controller.close(); return; } const chunk = buffer.subarray(offset, Math.min(offset + CHUNK_SIZE, length)); controller.enqueue(chunk); offset += chunk.length; uploaded += chunk.length; onProgress(uploaded); } // Push all chunks synchronously since data is already in memory while (offset < length) { const chunk = buffer.subarray(offset, Math.min(offset + CHUNK_SIZE, length)); controller.enqueue(chunk); offset += chunk.length; uploaded += chunk.length; onProgress(uploaded); } controller.close(); }, }); const res = await fetch(url, { method: "PUT", headers: { "Content-Type": CONTENT_TYPE, "Content-Length": String(length), }, body: stream, // @ts-ignore - duplex is needed for streaming uploads in Node duplex: "half", }); if (!res.ok) { const text = await res.text(); throw new Error(`PUT part ${partNumber} failed (${res.status}): ${text}`); } const etag = res.headers.get("etag"); if (!etag) { throw new Error(`No ETag returned for part ${partNumber}`); } return etag; } // ─── UI Component ──────────────────────────────────────────────────────────── const BAR_WIDTH = 30; function ProgressBar({ bytes, totalBytes }: { bytes: number; totalBytes: number }) { const pct = Math.min(100, Math.round((bytes / totalBytes) * 100)); const filled = Math.round((bytes / totalBytes) * BAR_WIDTH); const empty = BAR_WIDTH - filled; const uploadedMB = (bytes / (1024 * 1024)).toFixed(1); const totalMB = (totalBytes / (1024 * 1024)).toFixed(1); return ( {'█'.repeat(filled)} {'░'.repeat(empty)} {pct}% ({uploadedMB}/{totalMB} MB) ); } 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.progress && ( {step.logs.map((line, i) => ( {line} ))} )} {step.progress && ( )} ); } function App() { const [steps, setSteps] = useState([ { label: "Build Galaxy", status: "pending" }, { label: "Create Galaxy.zip", status: "pending" }, { label: "Authenticate with Hermes", status: "pending" }, { label: "Start multipart upload", status: "pending" }, { label: "Upload parts", status: "pending" }, { label: "Complete upload", status: "pending" }, { label: "Upload install-galaxy.sh", 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 { // ─── Step 0: Build ────────────────────────────────────────────── updateStep(0, { status: "running" }); await runCommandStreaming( "cargo bundle --bin galaxy-oss --package galaxy", WORKSPACE_ROOT, (line) => appendLog(0, line) ); updateStep(0, { status: "done" }); // ─── Step 1: Zip ──────────────────────────────────────────────── updateStep(1, { status: "running" }); // Find the .app bundle — cargo bundle outputs to target/debug/bundle/osx/ const appDir = path.join( WORKSPACE_ROOT, "target/debug/bundle/osx" ); const zipPath = path.join(WORKSPACE_ROOT, "Galaxy.zip"); // Remove old zip if exists execSync(`rm -f "${zipPath}"`, { stdio: "pipe" }); // Zip the .app folder await runCommandStreaming( `cd "${appDir}" && zip -r -y "${zipPath}" Galaxy.app`, WORKSPACE_ROOT, (line) => appendLog(1, line) ); const fileSize = statSync(zipPath).size; const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(1); updateStep(1, { status: "done", detail: `${fileSizeMB} MB` }); // ─── Step 2: Authenticate ─────────────────────────────────────── updateStep(2, { status: "running" }); const loginRes = await apiPost<{ token: string }>( `${BASE_URL}/api/auth/login`, { username: HERMES_USER, password: HERMES_PASS }, COMMON_HEADERS ); const token = loginRes.token; appendLog(2, `Authenticated as ${HERMES_USER}`); updateStep(2, { status: "done" }); // ─── Step 3: Start upload ─────────────────────────────────────── updateStep(3, { status: "running" }); const fileHash = computeFileHash(zipPath); const startRes = await apiPost( `${BASE_URL}/api/uploads/start`, { key: UPLOAD_KEY, contentType: CONTENT_TYPE, fileSize, fileHash, }, authHeaders(token) ); const { uploadId, urls: partUrls, totalParts, partSize } = startRes; appendLog(3, `Upload ID: ${uploadId.slice(0, 32)}...`); appendLog(3, `File hash: ${fileHash}`); appendLog(3, `File size: ${fileSizeMB} MB`); appendLog(3, `Parts: ${totalParts}`); updateStep(3, { status: "done", detail: `${totalParts} parts` }); // ─── Step 4: Upload parts ─────────────────────────────────────── updateStep(4, { status: "running", progress: { bytes: 0, totalBytes: fileSize } }); // Use the partSize from the server response const completedParts: { partNumber: number; etag: string }[] = []; let totalBytesUploaded = 0; for (const part of partUrls) { const prevPartsBytes = totalBytesUploaded; const etag = await uploadPart( part.url, zipPath, part.partNumber, partSize, totalParts, (partBytes) => { updateStep(4, { status: "running", progress: { bytes: prevPartsBytes + partBytes, totalBytes: fileSize }, }); } ); // Update total bytes for next part's baseline const thisPartSize = part.partNumber === totalParts ? fileSize - (partSize * (totalParts - 1)) : partSize; totalBytesUploaded += thisPartSize; completedParts.push({ partNumber: part.partNumber, etag }); // Notify server of part completion await apiPost( `${BASE_URL}/api/uploads/part-complete`, { uploadId, partNumber: part.partNumber, etag, }, authHeaders(token) ); } updateStep(4, { status: "done", detail: `${fileSizeMB} MB uploaded`, progress: undefined, }); // ─── Step 5: Complete ──────────────────────────────────────────── updateStep(5, { status: "running" }); await apiPost( `${BASE_URL}/api/uploads/complete`, { key: UPLOAD_KEY, uploadId, parts: completedParts.sort((a, b) => a.partNumber - b.partNumber), }, authHeaders(token) ); appendLog(5, `Key: ${UPLOAD_KEY}`); updateStep(5, { status: "done" }); // ─── Step 6: Upload install-galaxy.sh ──────────────────────────── updateStep(6, { status: "running" }); const installScriptPath = path.join(WORKSPACE_ROOT, "script", "install-galaxy.sh"); const scriptFileSize = statSync(installScriptPath).size; const scriptFileHash = computeFileHash(installScriptPath); const scriptStartRes = await apiPost( `${BASE_URL}/api/uploads/start`, { key: INSTALL_SCRIPT_KEY, contentType: SCRIPT_CONTENT_TYPE, fileSize: scriptFileSize, fileHash: scriptFileHash, }, authHeaders(token) ); const scriptCompletedParts: { partNumber: number; etag: string }[] = []; let scriptBytesUploaded = 0; updateStep(6, { status: "running", progress: { bytes: 0, totalBytes: scriptFileSize } }); for (const part of scriptStartRes.urls) { const prevBytes = scriptBytesUploaded; const etag = await uploadPart( part.url, installScriptPath, part.partNumber, scriptStartRes.partSize, scriptStartRes.totalParts, (partBytes) => { updateStep(6, { status: "running", progress: { bytes: prevBytes + partBytes, totalBytes: scriptFileSize }, }); } ); const thisPartSize = part.partNumber === scriptStartRes.totalParts ? scriptFileSize - (scriptStartRes.partSize * (scriptStartRes.totalParts - 1)) : scriptStartRes.partSize; scriptBytesUploaded += thisPartSize; scriptCompletedParts.push({ partNumber: part.partNumber, etag }); await apiPost( `${BASE_URL}/api/uploads/part-complete`, { uploadId: scriptStartRes.uploadId, partNumber: part.partNumber, etag, }, authHeaders(token) ); } await apiPost( `${BASE_URL}/api/uploads/complete`, { key: INSTALL_SCRIPT_KEY, uploadId: scriptStartRes.uploadId, parts: scriptCompletedParts.sort((a, b) => a.partNumber - b.partNumber), }, authHeaders(token) ); appendLog(6, `Key: ${INSTALL_SCRIPT_KEY}`); updateStep(6, { status: "done", detail: `${(scriptFileSize / 1024).toFixed(1)} KB`, progress: undefined }); // Cleanup zip execSync(`rm -f "${zipPath}"`, { stdio: "pipe" }); } catch (err: any) { // Mark current running step as error setSteps((prev) => prev.map((s) => s.status === "running" ? { ...s, status: "error" as StepStatus, detail: err.message } : s ) ); } })(); }, []); const allDone = steps.every((s) => s.status === "done" || s.status === "error"); const hasError = steps.some((s) => s.status === "error"); return ( 🚀 Build & Deploy Galaxy → Hermes {steps.map((step, i) => ( ))} {allDone && ( {hasError ? ( ✗ Deploy failed. ) : ( ✓ Deploy complete! )} )} ); } render();