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
118 changes: 118 additions & 0 deletions packages/cli/src/config/extensions/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<typeof import('tar')>();
return {
...actual,
x: mocks.tarX,
};
});
vi.mock('extract-zip', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
return {
...actual,
default: mocks.extract,
};
});

describe('git extension helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -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<typeof import('tar')>('tar');
mocks.tarX.mockImplementation(actualTar.x);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actualExtract = await vi.importActual<any>('extract-zip');
mocks.extract.mockImplementation(actualExtract.default);
});

afterEach(async () => {
Expand Down Expand Up @@ -484,4 +514,92 @@ describe('git extension helpers', () => {
).rejects.toThrow('Unsupported file extension for extraction:');
});
});

describe('downloadFromGitHubRelease', () => {
let tempDir: string;
let mockHttpsGet: MockedFunction<typeof https.get>;

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),
);
});
});
});
38 changes: 29 additions & 9 deletions packages/cli/src/config/extensions/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -457,24 +465,36 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
return undefined;
}

async function downloadFile(url: string, dest: string): Promise<void> {
const headers: {
'User-agent': string;
Accept: string;
Authorization?: string;
} = {
interface DownloadOptions {
headers?: Record<string, string>;
}

async function downloadFile(
url: string,
dest: string,
options?: DownloadOptions,
): Promise<void> {
const headers: Record<string, string> = {
'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) {
Expand Down