Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/commands/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -201,6 +203,52 @@ async function detectInstallMethod(): Promise<InstallMethod> {
};
}

// ---------------------------------------------------------------------------
// 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<void> {
logDebug("Upgrading via binary download for tag:", tag);
const artifact = getArtifactInfo();
Expand All @@ -216,11 +264,18 @@ async function upgradeBinary(tag: string): Promise<void> {
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.`
Expand Down
59 changes: 56 additions & 3 deletions src/helpers/binary-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,35 @@ 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
// ---------------------------------------------------------------------------

/**
* 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<string> {
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${tag}`;
const archiveUrl = `${baseUrl}/${artifact.name}${artifact.ext}`;
Expand All @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions tests/helpers/binary-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import {
type DownloadProgressCallback,
cleanupStaleBinary,
getArtifactInfo,
getManualInstallHint,
Expand Down Expand Up @@ -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<Uint8Array>({
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<Uint8Array>({
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", () => {
Expand Down
Loading