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
10 changes: 9 additions & 1 deletion cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { logger } from '../utils/logger.js';
import {
getLatestRelease,
getAssetUrl,
getChecksumUrl,
downloadRelease,
GitHubRateLimitError,
GitHubDownloadError,
GitHubChecksumError,
} from '../utils/github.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -52,8 +54,10 @@ async function tryGitHubInstall(
spinner.text = `Downloading ${release.tag_name}...`;
tempDir = await createTempDir();
const zipPath = join(tempDir, 'release.zip');
const assetName = assetUrl.split('/').pop() ?? 'release.zip';
const checksumUrl = getChecksumUrl(release, assetName) ?? undefined;

await downloadRelease(assetUrl, zipPath);
await downloadRelease(assetUrl, zipPath, checksumUrl);

spinner.text = 'Extracting and installing files...';
const { copiedFolders, tempDir: extractedTempDir } = await installFromZip(
Expand All @@ -72,6 +76,10 @@ async function tryGitHubInstall(
await cleanup(tempDir);
}

if (error instanceof GitHubChecksumError) {
throw error;
}

if (error instanceof GitHubRateLimitError) {
spinner.warn('GitHub rate limit reached, using template generation...');
return null;
Expand Down
32 changes: 31 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 All @@ -19,6 +20,13 @@ export class GitHubDownloadError extends Error {
}
}

export class GitHubChecksumError extends Error {
constructor(message: string) {
super(message);
this.name = 'GitHubChecksumError';
}
}

function checkRateLimit(response: Response): void {
const remaining = response.headers.get('x-ratelimit-remaining');
if (response.status === 403 && remaining === '0') {
Expand Down Expand Up @@ -69,7 +77,14 @@ export async function getLatestRelease(): Promise<Release> {
return response.json();
}

export async function downloadRelease(url: string, dest: string): Promise<void> {
export function getChecksumUrl(release: Release, assetName: string): string | null {
const checksumAsset = release.assets.find(
a => a.name === `${assetName}.sha256` || a.name === 'checksums.txt'
);
return checksumAsset?.browser_download_url ?? null;
}

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

const buffer = await response.arrayBuffer();

if (checksumUrl) {
const checksumResponse = await fetch(checksumUrl, { headers: { 'User-Agent': 'uipro-cli' } });
if (checksumResponse.ok) {
const checksumText = (await checksumResponse.text()).trim();
const expectedHash = checksumText.split(/\s+/)[0];
const actualHash = createHash('sha256').update(Buffer.from(buffer)).digest('hex');
if (actualHash !== expectedHash) {
throw new GitHubChecksumError(
`Checksum mismatch for downloaded release: expected ${expectedHash}, got ${actualHash}`
);
}
}
}

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

Expand Down