diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 1b1f3b0e829..82510a80f2b 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -20,6 +20,7 @@ import { findReleaseAsset, fetchReleaseFromGithub, tryParseGithubUrl, + downloadFromGitHubRelease, } from './github.js'; import { simpleGit, type SimpleGit } from 'simple-git'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; @@ -29,6 +30,8 @@ import * as fsSync from 'node:fs'; import * as path from 'node:path'; import * as tar from 'tar'; import * as archiver from 'archiver'; +import { Readable } from 'node:stream'; +import * as https from 'node:https'; import type { GeminiCLIExtension } from '@google/gemini-cli-core'; import { ExtensionManager } from '../extension-manager.js'; import { loadSettings } from '../settings.js'; @@ -56,6 +59,28 @@ vi.mock('./github_fetch.js', async (importOriginal) => { }; }); +const mocks = vi.hoisted(() => ({ + tarX: vi.fn(), + extract: vi.fn(), +})); + +vi.mock('node:https'); +vi.mock('tar', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + x: mocks.tarX, + }; +}); +vi.mock('extract-zip', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actual = await importOriginal(); + return { + ...actual, + default: mocks.extract, + }; +}); + describe('git extension helpers', () => { afterEach(() => { vi.restoreAllMocks(); @@ -411,6 +436,11 @@ describe('git extension helpers', () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const actualTar = await vi.importActual('tar'); + mocks.tarX.mockImplementation(actualTar.x); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actualExtract = await vi.importActual('extract-zip'); + mocks.extract.mockImplementation(actualExtract.default); }); afterEach(async () => { @@ -484,4 +514,92 @@ describe('git extension helpers', () => { ).rejects.toThrow('Unsupported file extension for extraction:'); }); }); + + describe('downloadFromGitHubRelease', () => { + let tempDir: string; + let mockHttpsGet: MockedFunction; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + mocks.tarX.mockResolvedValue(undefined); + mocks.extract.mockResolvedValue(undefined); + + mockHttpsGet = vi.mocked(https.get); + mockHttpsGet.mockImplementation((_url, _options, callback) => { + const response = new Readable(); + response.push('dummy content'); + response.push(null); + // @ts-expect-error: Mocking statusCode on Readable stream + response.statusCode = 200; + // @ts-expect-error: Mocking callback with Readable stream + callback(response); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { on: vi.fn() } as any; + }); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should use application/octet-stream for binary assets', async () => { + const installMetadata = { + source: 'owner/repo', + type: 'github-release' as const, + ref: 'v1.0.0', + }; + const githubRepoInfo = { owner: 'owner', repo: 'repo' }; + + fetchJsonMock.mockResolvedValue({ + tag_name: 'v1.0.0', + assets: [ + { + name: 'darwin.arm64.extension.tar.gz', + url: 'https://api.github.com/assets/1', + }, + ], + }); + mockPlatform.mockReturnValue('darwin'); + mockArch.mockReturnValue('arm64'); + + await downloadFromGitHubRelease(installMetadata, tempDir, githubRepoInfo); + + expect(mockHttpsGet).toHaveBeenCalledWith( + 'https://api.github.com/assets/1', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/octet-stream', + }), + }), + expect.any(Function), + ); + }); + + it('should use application/vnd.github+json for source tarballs', async () => { + const installMetadata = { + source: 'owner/repo', + type: 'github-release' as const, + ref: 'v1.0.0', + }; + const githubRepoInfo = { owner: 'owner', repo: 'repo' }; + + fetchJsonMock.mockResolvedValue({ + tag_name: 'v1.0.0', + assets: [], + tarball_url: 'https://api.github.com/repos/owner/repo/tarball/v1.0.0', + }); + + await downloadFromGitHubRelease(installMetadata, tempDir, githubRepoInfo); + + expect(mockHttpsGet).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/tarball/v1.0.0', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/vnd.github+json', + }), + }), + expect.any(Function), + ); + }); + }); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 4f5316e2d7a..ed8f5bf9de5 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -340,7 +340,15 @@ export async function downloadFromGitHubRelease( } try { - await downloadFile(archiveUrl, downloadedAssetPath); + // GitHub API requires different Accept headers for different types of downloads: + // 1. Binary Assets (e.g. release artifacts): Require 'application/octet-stream' to return the raw content. + // 2. Source Tarballs (e.g. /tarball/{ref}): Require 'application/vnd.github+json' (or similar) to return + // a 302 Redirect to the actual download location (codeload.github.com). + // Sending 'application/octet-stream' for tarballs results in a 415 Unsupported Media Type error. + const headers = asset + ? { Accept: 'application/octet-stream' } + : { Accept: 'application/vnd.github+json' }; + await downloadFile(archiveUrl, downloadedAssetPath, { headers }); } catch (error) { return { failureReason: 'failed to download asset', @@ -457,24 +465,36 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined { return undefined; } -async function downloadFile(url: string, dest: string): Promise { - const headers: { - 'User-agent': string; - Accept: string; - Authorization?: string; - } = { +interface DownloadOptions { + headers?: Record; +} + +async function downloadFile( + url: string, + dest: string, + options?: DownloadOptions, +): Promise { + const headers: Record = { 'User-agent': 'gemini-cli', Accept: 'application/octet-stream', + ...options?.headers, }; const token = getGitHubToken(); if (token) { - headers.Authorization = `token ${token}`; + headers['Authorization'] = `token ${token}`; } return new Promise((resolve, reject) => { https .get(url, { headers }, (res) => { if (res.statusCode === 302 || res.statusCode === 301) { - downloadFile(res.headers.location!, dest).then(resolve).catch(reject); + if (!res.headers.location) { + return reject( + new Error('Redirect response missing Location header'), + ); + } + downloadFile(res.headers.location, dest, options) + .then(resolve) + .catch(reject); return; } if (res.statusCode !== 200) {