mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Pre releases (#10752)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -35,7 +35,9 @@ Gemini CLI extensions can be distributed through [GitHub Releases](https://docs.
|
||||
|
||||
Each release includes at least one archive file, which contains the full contents of the repo at the tag that it was linked to. Releases may also include [pre-built archives](#custom-pre-built-archives) if your extension requires some build step or has platform specific binaries attached to it.
|
||||
|
||||
When checking for updates, gemini will just look for the latest release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=<some-release-tag>`. We do not at this time support opting in to pre-release releases or semver.
|
||||
When checking for updates, gemini will just look for the "latest" release on github (you must mark it as such when creating the release), unless the user installed a specific release by passing `--ref=<some-release-tag>`.
|
||||
|
||||
You may also install extensions with the `--pre-release` flag in order to get the latest release regardless of whether it has been marked as "latest". This allows you to test that your release works before actually pushing it to all users.
|
||||
|
||||
### Custom pre-built archives
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ interface InstallArgs {
|
||||
source: string;
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
allowPreRelease?: boolean;
|
||||
}
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
@@ -34,6 +35,7 @@ export async function handleInstall(args: InstallArgs) {
|
||||
type: 'git',
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
};
|
||||
} else {
|
||||
if (args.ref || args.autoUpdate) {
|
||||
@@ -64,7 +66,7 @@ export async function handleInstall(args: InstallArgs) {
|
||||
}
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install <source>',
|
||||
command: 'install <source> [--auto-update] [--pre-release]',
|
||||
describe: 'Installs an extension from a git repository URL or a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
@@ -81,6 +83,10 @@ export const installCommand: CommandModule = {
|
||||
describe: 'Enable auto-update for this extension.',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('pre-release', {
|
||||
describe: 'Enable pre-release versions for this extension.',
|
||||
type: 'boolean',
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.source) {
|
||||
throw new Error('The source argument must be provided.');
|
||||
@@ -92,6 +98,7 @@ export const installCommand: CommandModule = {
|
||||
source: argv['source'] as string,
|
||||
ref: argv['ref'] as string | undefined,
|
||||
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
cloneFromGit,
|
||||
extractFile,
|
||||
findReleaseAsset,
|
||||
fetchReleaseFromGithub,
|
||||
parseGitHubRepoForReleases,
|
||||
} from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
@@ -35,6 +36,15 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
|
||||
vi.mock('simple-git');
|
||||
|
||||
const fetchJsonMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./github_fetch.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./github_fetch.js')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchJson: fetchJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('git extension helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -223,6 +233,66 @@ describe('git extension helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchReleaseFromGithub', () => {
|
||||
it('should fetch the latest release if allowPreRelease is true', async () => {
|
||||
const releases = [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }];
|
||||
fetchJsonMock.mockResolvedValueOnce(releases);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases?per_page=1',
|
||||
);
|
||||
expect(result).toEqual(releases[0]);
|
||||
});
|
||||
|
||||
it('should fetch the latest release if allowPreRelease is false', async () => {
|
||||
const release = { tag_name: 'v0.9.0' };
|
||||
fetchJsonMock.mockResolvedValueOnce(release);
|
||||
|
||||
const result = await fetchReleaseFromGithub(
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
);
|
||||
expect(result).toEqual(release);
|
||||
});
|
||||
|
||||
it('should fetch a release by tag if ref is provided', async () => {
|
||||
const release = { tag_name: 'v0.9.0' };
|
||||
fetchJsonMock.mockResolvedValueOnce(release);
|
||||
|
||||
const result = await fetchReleaseFromGithub('owner', 'repo', 'v0.9.0');
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
|
||||
);
|
||||
expect(result).toEqual(release);
|
||||
});
|
||||
|
||||
it('should fetch latest stable release if allowPreRelease is undefined', async () => {
|
||||
const release = { tag_name: 'v0.9.0' };
|
||||
fetchJsonMock.mockResolvedValueOnce(release);
|
||||
|
||||
const result = await fetchReleaseFromGithub('owner', 'repo');
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/repos/owner/repo/releases/latest',
|
||||
);
|
||||
expect(result).toEqual(release);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReleaseAsset', () => {
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' },
|
||||
|
||||
@@ -18,10 +18,7 @@ import * as path from 'node:path';
|
||||
import { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js';
|
||||
import * as tar from 'tar';
|
||||
import extract from 'extract-zip';
|
||||
|
||||
function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
import { fetchJson, getGitHubToken } from './github_fetch.js';
|
||||
|
||||
/**
|
||||
* Clones a Git repository to a specified local path.
|
||||
@@ -108,14 +105,34 @@ export function parseGitHubRepoForReleases(source: string): {
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
async function fetchReleaseFromGithub(
|
||||
export async function fetchReleaseFromGithub(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref?: string,
|
||||
allowPreRelease?: boolean,
|
||||
): Promise<GithubReleaseData> {
|
||||
const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest';
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`;
|
||||
return await fetchJson(url);
|
||||
if (ref) {
|
||||
return await fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowPreRelease) {
|
||||
// Grab the release that is tagged as the "latest", github does not allow
|
||||
// this to be a pre-release so we can blindly grab it.
|
||||
return await fetchJson(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
|
||||
);
|
||||
}
|
||||
|
||||
// If pre-releases are allowed, we just grab the most recent release.
|
||||
const releases = await fetchJson<GithubReleaseData[]>(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`,
|
||||
);
|
||||
if (releases.length === 0) {
|
||||
throw new Error('No releases found');
|
||||
}
|
||||
return releases[0];
|
||||
}
|
||||
|
||||
export async function checkForExtensionUpdate(
|
||||
@@ -195,6 +212,7 @@ export async function checkForExtensionUpdate(
|
||||
owner,
|
||||
repo,
|
||||
installMetadata.ref,
|
||||
installMetadata.allowPreRelease,
|
||||
);
|
||||
if (releaseData.tag_name !== releaseTag) {
|
||||
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
@@ -216,11 +234,16 @@ export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<GitHubDownloadResult> {
|
||||
const { source, ref } = installMetadata;
|
||||
const { source, ref, allowPreRelease: preRelease } = installMetadata;
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
|
||||
try {
|
||||
const releaseData = await fetchReleaseFromGithub(owner, repo, ref);
|
||||
const releaseData = await fetchReleaseFromGithub(
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
preRelease,
|
||||
);
|
||||
if (!releaseData) {
|
||||
throw new Error(
|
||||
`No release data found for ${owner}/${repo} at tag ${ref}`,
|
||||
@@ -350,33 +373,6 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const headers: { 'User-Agent': string; Authorization?: string } = {
|
||||
'User-Agent': 'gemini-cli',
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const headers: { 'User-agent': string; Authorization?: string } = {
|
||||
'User-agent': 'gemini-cli',
|
||||
|
||||
38
packages/cli/src/config/extensions/github_fetch.ts
Normal file
38
packages/cli/src/config/extensions/github_fetch.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
|
||||
export function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
const headers: { 'User-Agent': string; Authorization?: string } = {
|
||||
'User-Agent': 'gemini-cli',
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(
|
||||
new Error(`Request failed with status code ${res.statusCode}`),
|
||||
);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,7 @@ export interface ExtensionInstallMetadata {
|
||||
releaseTag?: string; // Only present for github-release installs.
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
allowPreRelease?: boolean;
|
||||
}
|
||||
|
||||
import type { FileFilteringOptions } from './constants.js';
|
||||
|
||||
Reference in New Issue
Block a user