diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 6b0f6682..e41c875a 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,10 +1,12 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; +import { clearLine, cursorTo } from "node:readline"; import type { Command } from "@commander-js/extra-typings"; import { semver } from "bun"; import { + type DownloadProgressCallback, downloadReleaseBinary, fetchLatestGitHubVersion, getArtifactInfo, @@ -201,6 +203,52 @@ async function detectInstallMethod(): Promise { }; } +// --------------------------------------------------------------------------- +// Download progress display +// --------------------------------------------------------------------------- + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Create a progress callback that renders an updating line on stderr. + * Returns `undefined` when stderr is not a TTY (piped / CI) — in that case + * the download runs silently and the existing "Upgrading X -> Y..." message + * is sufficient feedback. Per ARCH-003: no progress output without a TTY. + */ +function createDownloadProgress(): DownloadProgressCallback | undefined { + if (!process.stderr.isTTY) return undefined; + + return ({ downloadedBytes, totalBytes }) => { + clearLine(process.stderr, 0); + cursorTo(process.stderr, 0); + const downloaded = formatBytes(downloadedBytes); + if (totalBytes) { + const total = formatBytes(totalBytes); + const percent = Math.round((downloadedBytes / totalBytes) * 100); + process.stderr.write( + `Downloading... ${downloaded} / ${total} (${percent}%)` + ); + } else { + process.stderr.write(`Downloading... ${downloaded}`); + } + }; +} + +/** Clear the progress line so subsequent output starts on a fresh line. */ +function finishDownloadProgress(): void { + if (!process.stderr.isTTY) return; + clearLine(process.stderr, 0); + cursorTo(process.stderr, 0); +} + +// --------------------------------------------------------------------------- +// Binary upgrade +// --------------------------------------------------------------------------- + async function upgradeBinary(tag: string): Promise { logDebug("Upgrading via binary download for tag:", tag); const artifact = getArtifactInfo(); @@ -216,11 +264,18 @@ async function upgradeBinary(tag: string): Promise { logDebug("Artifact:", artifact.name, "ext:", artifact.ext); const hint = getManualInstallHint(); try { - const newBinaryPath = await downloadReleaseBinary(tag, artifact); + const onProgress = createDownloadProgress(); + const newBinaryPath = await downloadReleaseBinary( + tag, + artifact, + onProgress + ); + finishDownloadProgress(); logDebug("Downloaded binary to:", newBinaryPath); logDebug("Replacing binary:", process.execPath); replaceBinary(process.execPath, newBinaryPath); } catch (err) { + finishDownloadProgress(); logError( "Failed to upgrade binary.", `${err instanceof Error ? err.message : String(err)}\nTry running \`${hint}\` manually.` diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts index 3fc70c2d..b846c202 100644 --- a/src/helpers/binary-upgrade.ts +++ b/src/helpers/binary-upgrade.ts @@ -94,6 +94,19 @@ export async function fetchLatestGitHubVersion( return data.tag_name ?? null; } +// --------------------------------------------------------------------------- +// Download progress +// --------------------------------------------------------------------------- + +export interface DownloadProgress { + /** Bytes received so far. */ + downloadedBytes: number; + /** Total expected bytes (`null` when Content-Length is absent). */ + totalBytes: number | null; +} + +export type DownloadProgressCallback = (progress: DownloadProgress) => void; + // --------------------------------------------------------------------------- // Download and extract // --------------------------------------------------------------------------- @@ -101,10 +114,15 @@ export async function fetchLatestGitHubVersion( /** * Download and extract the release binary to a temp directory. * Returns the path to the extracted binary. + * + * When an `onProgress` callback is provided the response body is streamed + * so the caller can display incremental progress. Without the callback the + * response is buffered in one shot (legacy behaviour). */ export async function downloadReleaseBinary( tag: string, - artifact: ArtifactInfo + artifact: ArtifactInfo, + onProgress?: DownloadProgressCallback ): Promise { const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${tag}`; const archiveUrl = `${baseUrl}/${artifact.name}${artifact.ext}`; @@ -113,14 +131,49 @@ export async function downloadReleaseBinary( logDebug("Downloading binary from:", archiveUrl); const response = await fetch(archiveUrl, { headers: { "User-Agent": "archgate-cli" }, - signal: AbortSignal.timeout(60_000), + // 5 minutes — release binaries can exceed 100 MB which may take a + // while on slower connections. The previous 60 s limit caused + // timeouts for many users. + signal: AbortSignal.timeout(300_000), }); if (!response.ok) { throw new Error(`Download failed (HTTP ${response.status})`); } - const buffer = await response.arrayBuffer(); + let buffer: ArrayBuffer; + + if (onProgress && response.body) { + // Stream the response so we can report progress incrementally. + const contentLength = response.headers.get("content-length"); + const totalBytes = contentLength ? parseInt(contentLength, 10) : null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let downloadedBytes = 0; + + while (true) { + // oxlint-disable-next-line no-await-in-loop -- sequential streaming is intentional; each chunk depends on the previous read + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + downloadedBytes += value.byteLength; + onProgress({ downloadedBytes, totalBytes }); + } + + // Combine chunks into a single contiguous buffer. + const combined = new Uint8Array(downloadedBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + buffer = combined.buffer as ArrayBuffer; + } else { + buffer = await response.arrayBuffer(); + } + logDebug("Downloaded", Math.round(buffer.byteLength / 1024), "KB"); // Verify SHA256 checksum when available (releases after this change) diff --git a/tests/helpers/binary-upgrade.test.ts b/tests/helpers/binary-upgrade.test.ts index 8c541217..536c5eb1 100644 --- a/tests/helpers/binary-upgrade.test.ts +++ b/tests/helpers/binary-upgrade.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + type DownloadProgressCallback, cleanupStaleBinary, getArtifactInfo, getManualInstallHint, @@ -124,6 +125,113 @@ describe("downloadReleaseBinary", () => { "Download failed (HTTP 404)" ); }); + + test("calls onProgress callback with streaming progress", async () => { + // Create a fake ReadableStream that yields two chunks + const chunk1 = new Uint8Array([1, 2, 3, 4]); + const chunk2 = new Uint8Array([5, 6, 7, 8, 9, 10]); + const totalSize = chunk1.byteLength + chunk2.byteLength; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk1); + controller.enqueue(chunk2); + controller.close(); + }, + }); + + // First call: archive download (with streaming body) + // Second call: checksum fetch (returns 404 — skipped) + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + headers: new Headers({ "content-length": String(totalSize) }), + body: stream, + } as Response); + } + // Checksum fetch — not available + return Promise.resolve({ ok: false, status: 404 } as Response); + }) as unknown as typeof fetch; + + const progressCalls: Array<{ + downloadedBytes: number; + totalBytes: number | null; + }> = []; + const onProgress: DownloadProgressCallback = (info) => { + progressCalls.push({ ...info }); + }; + + const artifact = { + name: "archgate-linux-x64", + ext: ".tar.gz", + binaryName: "archgate", + }; + + // downloadReleaseBinary will fail at extraction (no real tar), + // but progress callbacks should still have been called. + try { + await downloadReleaseBinary("v1.0.0", artifact, onProgress); + } catch { + // Expected — the fake data is not a valid archive + } + + expect(progressCalls).toHaveLength(2); + + expect(progressCalls[0].downloadedBytes).toBe(chunk1.byteLength); + expect(progressCalls[0].totalBytes).toBe(totalSize); + + expect(progressCalls[1].downloadedBytes).toBe(totalSize); + expect(progressCalls[1].totalBytes).toBe(totalSize); + }); + + test("streams without totalBytes when content-length is absent", async () => { + const chunk = new Uint8Array([1, 2, 3]); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.close(); + }, + }); + + let callCount = 0; + globalThis.fetch = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + headers: new Headers(), // no content-length + body: stream, + } as Response); + } + return Promise.resolve({ ok: false, status: 404 } as Response); + }) as unknown as typeof fetch; + + const progressCalls: Array<{ + downloadedBytes: number; + totalBytes: number | null; + }> = []; + + const artifact = { + name: "archgate-linux-x64", + ext: ".tar.gz", + binaryName: "archgate", + }; + + try { + await downloadReleaseBinary("v1.0.0", artifact, (info) => { + progressCalls.push({ ...info }); + }); + } catch { + // Expected — fake data is not a valid archive + } + + expect(progressCalls).toHaveLength(1); + expect(progressCalls[0].downloadedBytes).toBe(chunk.byteLength); + expect(progressCalls[0].totalBytes).toBeNull(); + }); }); describe("replaceBinary", () => {