Skip to content
Open
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
5 changes: 4 additions & 1 deletion cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getLatestRelease,
getAssetUrl,
downloadRelease,
findChecksumAsset,
GitHubRateLimitError,
GitHubDownloadError,
} from '../utils/github.js';
Expand Down Expand Up @@ -53,7 +54,9 @@ async function tryGitHubInstall(
tempDir = await createTempDir();
const zipPath = join(tempDir, 'release.zip');

await downloadRelease(assetUrl, zipPath);
const assetFileName = assetUrl.split('/').pop() ?? 'release.zip';
const expectedSha256 = await findChecksumAsset(release, assetFileName);
await downloadRelease(assetUrl, zipPath, expectedSha256 ?? undefined);

spinner.text = 'Extracting and installing files...';
const { copiedFolders, tempDir: extractedTempDir } = await installFromZip(
Expand Down
31 changes: 30 additions & 1 deletion cli/src/utils/github.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import type { Release } from '../types/index.js';

Expand Down Expand Up @@ -69,7 +70,7 @@ export async function getLatestRelease(): Promise<Release> {
return response.json();
}

export async function downloadRelease(url: string, dest: string): Promise<void> {
export async function downloadRelease(url: string, dest: string, expectedSha256?: string): Promise<void> {
const response = await fetch(url, {
headers: {
'User-Agent': 'uipro-cli',
Expand All @@ -84,9 +85,37 @@ export async function downloadRelease(url: string, dest: string): Promise<void>
}

const buffer = await response.arrayBuffer();

if (expectedSha256) {
const actual = createHash('sha256').update(Buffer.from(buffer)).digest('hex');
if (actual !== expectedSha256) {
throw new GitHubDownloadError(
`SHA-256 mismatch — download may be corrupted or tampered.
Expected: ${expectedSha256}
Actual: ${actual}`
);
}
}

await writeFile(dest, Buffer.from(buffer));
}

export async function findChecksumAsset(release: Release, assetName: string): Promise<string | null> {
const checksumNames = [`${assetName}.sha256`, 'checksums.sha256', 'SHA256SUMS'];
const checksumAsset = release.assets.find(a => checksumNames.includes(a.name));
if (!checksumAsset) return null;

const response = await fetch(checksumAsset.browser_download_url, {
headers: { 'User-Agent': 'uipro-cli' },
});
if (!response.ok) return null;

const text = await response.text();
// Format: "<hash> <filename>" or just "<hash>"
const match = text.trim().match(/^([0-9a-f]{64})/m);
return match ? match[1] : null;
}

export function getAssetUrl(release: Release): string | null {
// First try to find an uploaded ZIP asset
const asset = release.assets.find(a => a.name.endsWith('.zip'));
Expand Down