mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Add simple extensions release flow support (#8498)
This commit is contained in:
78
docs/extension-releasing.md
Normal file
78
docs/extension-releasing.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Extension Releasing
|
||||||
|
|
||||||
|
Gemini CLI extensions can be distributed as pre-built binaries through GitHub Releases. This provides a faster and more reliable installation experience for users, as it avoids the need to clone the repository and build the extension from source.
|
||||||
|
|
||||||
|
## Asset naming convention
|
||||||
|
|
||||||
|
To ensure Gemini CLI can automatically find the correct release asset for each platform, you should follow this naming convention. The CLI will search for assets in the following order:
|
||||||
|
|
||||||
|
1. **Platform and Architecture-Specific:** `{platform}.{arch}.{name}.{extension}`
|
||||||
|
2. **Platform-Specific:** `{platform}.{name}.{extension}`
|
||||||
|
3. **Generic:** If only one asset is provided, it will be used as a generic fallback.
|
||||||
|
|
||||||
|
- `{name}`: The name of your extension.
|
||||||
|
- `{platform}`: The operating system. Supported values are:
|
||||||
|
- `darwin` (macOS)
|
||||||
|
- `linux`
|
||||||
|
- `win32` (Windows)
|
||||||
|
- `{arch}`: The architecture. Supported values are:
|
||||||
|
- `x64`
|
||||||
|
- `arm64`
|
||||||
|
- `{extension}`: The file extension of the archive (e.g., `.tar.gz` or `.zip`).
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
- `darwin.arm64.my-tool.tar.gz` (specific to Apple Silicon Macs)
|
||||||
|
- `darwin.my-tool.tar.gz` (for all Macs)
|
||||||
|
- `linux.x64.my-tool.tar.gz`
|
||||||
|
- `win32.my-tool.zip`
|
||||||
|
|
||||||
|
If your extension is platform-independent, you can provide a single generic asset. In this case, there should be only one asset attached to the release.
|
||||||
|
|
||||||
|
## Archive structure
|
||||||
|
|
||||||
|
The `gemini-extension.json` file must be at the root of the archive.
|
||||||
|
|
||||||
|
## Example GitHub Actions workflow
|
||||||
|
|
||||||
|
Here is an example of a GitHub Actions workflow that builds and releases a Gemini CLI extension for multiple platforms:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release Extension
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build extension
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Create release assets
|
||||||
|
run: |
|
||||||
|
npm run package -- --platform=darwin --arch=arm64
|
||||||
|
npm run package -- --platform=linux --arch=x64
|
||||||
|
npm run package -- --platform=win32 --arch=x64
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
release/darwin.arm64.my-tool.tar.gz
|
||||||
|
release/linux.arm64.my-tool.tar.gz
|
||||||
|
release/win32.arm64.my-tool.zip
|
||||||
|
```
|
||||||
@@ -5,10 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import {
|
import { installExtension } from '../../config/extension.js';
|
||||||
installExtension,
|
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||||
type ExtensionInstallMetadata,
|
|
||||||
} from '../../config/extension.js';
|
|
||||||
|
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import {
|
import { installExtension } from '../../config/extension.js';
|
||||||
installExtension,
|
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||||
type ExtensionInstallMetadata,
|
|
||||||
} from '../../config/extension.js';
|
|
||||||
|
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
INSTALL_METADATA_FILENAME,
|
INSTALL_METADATA_FILENAME,
|
||||||
annotateActiveExtensions,
|
annotateActiveExtensions,
|
||||||
checkForAllExtensionUpdates,
|
checkForAllExtensionUpdates,
|
||||||
checkForExtensionUpdate,
|
|
||||||
disableExtension,
|
disableExtension,
|
||||||
enableExtension,
|
enableExtension,
|
||||||
installExtension,
|
installExtension,
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
uninstallExtension,
|
uninstallExtension,
|
||||||
updateExtension,
|
updateExtension,
|
||||||
type Extension,
|
type Extension,
|
||||||
type ExtensionInstallMetadata,
|
|
||||||
} from './extension.js';
|
} from './extension.js';
|
||||||
import {
|
import {
|
||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
@@ -32,6 +30,7 @@ import {
|
|||||||
ClearcutLogger,
|
ClearcutLogger,
|
||||||
type Config,
|
type Config,
|
||||||
ExtensionUninstallEvent,
|
ExtensionUninstallEvent,
|
||||||
|
type ExtensionInstallMetadata,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { SettingScope } from './settings.js';
|
import { SettingScope } from './settings.js';
|
||||||
@@ -1140,118 +1139,6 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checkForExtensionUpdate', () => {
|
|
||||||
it('should return UpdateAvailable for a git extension with updates', async () => {
|
|
||||||
const extensionDir = createExtension({
|
|
||||||
extensionsDir: userExtensionsDir,
|
|
||||||
name: 'test-extension',
|
|
||||||
version: '1.0.0',
|
|
||||||
installMetadata: {
|
|
||||||
source: 'https://some.git/repo',
|
|
||||||
type: 'git',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const extension = annotateActiveExtensions(
|
|
||||||
[
|
|
||||||
loadExtension({
|
|
||||||
extensionDir,
|
|
||||||
workspaceDir: tempWorkspaceDir,
|
|
||||||
})!,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
process.cwd(),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
mockGit.getRemotes.mockResolvedValue([
|
|
||||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
|
||||||
]);
|
|
||||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
|
||||||
mockGit.revparse.mockResolvedValue('localHash');
|
|
||||||
|
|
||||||
const result = await checkForExtensionUpdate(extension);
|
|
||||||
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return UpToDate for a git extension with no updates', async () => {
|
|
||||||
const extensionDir = createExtension({
|
|
||||||
extensionsDir: userExtensionsDir,
|
|
||||||
name: 'test-extension',
|
|
||||||
version: '1.0.0',
|
|
||||||
installMetadata: {
|
|
||||||
source: 'https://some.git/repo',
|
|
||||||
type: 'git',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const extension = annotateActiveExtensions(
|
|
||||||
[
|
|
||||||
loadExtension({
|
|
||||||
extensionDir,
|
|
||||||
workspaceDir: tempWorkspaceDir,
|
|
||||||
})!,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
process.cwd(),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
mockGit.getRemotes.mockResolvedValue([
|
|
||||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
|
||||||
]);
|
|
||||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
|
||||||
mockGit.revparse.mockResolvedValue('sameHash');
|
|
||||||
|
|
||||||
const result = await checkForExtensionUpdate(extension);
|
|
||||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return NotUpdatable for a non-git extension', async () => {
|
|
||||||
const extensionDir = createExtension({
|
|
||||||
extensionsDir: userExtensionsDir,
|
|
||||||
name: 'local-extension',
|
|
||||||
version: '1.0.0',
|
|
||||||
});
|
|
||||||
const extension = annotateActiveExtensions(
|
|
||||||
[
|
|
||||||
loadExtension({
|
|
||||||
extensionDir,
|
|
||||||
workspaceDir: tempWorkspaceDir,
|
|
||||||
})!,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
process.cwd(),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
const result = await checkForExtensionUpdate(extension);
|
|
||||||
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return Error when git check fails', async () => {
|
|
||||||
const extensionDir = createExtension({
|
|
||||||
extensionsDir: userExtensionsDir,
|
|
||||||
name: 'error-extension',
|
|
||||||
version: '1.0.0',
|
|
||||||
installMetadata: {
|
|
||||||
source: 'https://some.git/repo',
|
|
||||||
type: 'git',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const extension = annotateActiveExtensions(
|
|
||||||
[
|
|
||||||
loadExtension({
|
|
||||||
extensionDir,
|
|
||||||
workspaceDir: tempWorkspaceDir,
|
|
||||||
})!,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
process.cwd(),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
|
||||||
|
|
||||||
const result = await checkForExtensionUpdate(extension);
|
|
||||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('disableExtension', () => {
|
describe('disableExtension', () => {
|
||||||
it('should disable an extension at the user scope', () => {
|
it('should disable an extension at the user scope', () => {
|
||||||
disableExtension('my-extension', SettingScope.User);
|
disableExtension('my-extension', SettingScope.User);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type {
|
import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
GeminiCLIExtension,
|
GeminiCLIExtension,
|
||||||
|
ExtensionInstallMetadata,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
@@ -19,7 +20,6 @@ import {
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import { simpleGit } from 'simple-git';
|
|
||||||
import { SettingScope, loadSettings } from '../config/settings.js';
|
import { SettingScope, loadSettings } from '../config/settings.js';
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||||
@@ -27,6 +27,11 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
|
|||||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { ExtensionUpdateState } from '../ui/state/extensions.js';
|
import { ExtensionUpdateState } from '../ui/state/extensions.js';
|
||||||
|
import {
|
||||||
|
cloneFromGit,
|
||||||
|
checkForExtensionUpdate,
|
||||||
|
downloadFromGitHubRelease,
|
||||||
|
} from './extensions/github.js';
|
||||||
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
||||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||||
|
|
||||||
@@ -50,12 +55,6 @@ export interface ExtensionConfig {
|
|||||||
excludeTools?: string[];
|
excludeTools?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionInstallMetadata {
|
|
||||||
source: string;
|
|
||||||
type: 'git' | 'local' | 'link';
|
|
||||||
ref?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtensionUpdateInfo {
|
export interface ExtensionUpdateInfo {
|
||||||
name: string;
|
name: string;
|
||||||
originalVersion: string;
|
originalVersion: string;
|
||||||
@@ -301,7 +300,6 @@ export function annotateActiveExtensions(
|
|||||||
const manager = new ExtensionEnablementManager(
|
const manager = new ExtensionEnablementManager(
|
||||||
ExtensionStorage.getUserExtensionsDir(),
|
ExtensionStorage.getUserExtensionsDir(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const annotatedExtensions: GeminiCLIExtension[] = [];
|
const annotatedExtensions: GeminiCLIExtension[] = [];
|
||||||
if (enabledExtensionNames.length === 0) {
|
if (enabledExtensionNames.length === 0) {
|
||||||
return extensions.map((extension) => ({
|
return extensions.map((extension) => ({
|
||||||
@@ -309,9 +307,7 @@ export function annotateActiveExtensions(
|
|||||||
version: extension.config.version,
|
version: extension.config.version,
|
||||||
isActive: manager.isEnabled(extension.config.name, workspaceDir),
|
isActive: manager.isEnabled(extension.config.name, workspaceDir),
|
||||||
path: extension.path,
|
path: extension.path,
|
||||||
source: extension.installMetadata?.source,
|
installMetadata: extension.installMetadata,
|
||||||
type: extension.installMetadata?.type,
|
|
||||||
ref: extension.installMetadata?.ref,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,9 +324,7 @@ export function annotateActiveExtensions(
|
|||||||
version: extension.config.version,
|
version: extension.config.version,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
path: extension.path,
|
path: extension.path,
|
||||||
source: extension.installMetadata?.source,
|
installMetadata: extension.installMetadata,
|
||||||
type: extension.installMetadata?.type,
|
|
||||||
ref: extension.installMetadata?.ref,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +343,7 @@ export function annotateActiveExtensions(
|
|||||||
version: extension.config.version,
|
version: extension.config.version,
|
||||||
isActive,
|
isActive,
|
||||||
path: extension.path,
|
path: extension.path,
|
||||||
|
installMetadata: extension.installMetadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,43 +354,6 @@ export function annotateActiveExtensions(
|
|||||||
return annotatedExtensions;
|
return annotatedExtensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clones a Git repository to a specified local path.
|
|
||||||
* @param installMetadata The metadata for the extension to install.
|
|
||||||
* @param destination The destination path to clone the repository to.
|
|
||||||
*/
|
|
||||||
async function cloneFromGit(
|
|
||||||
installMetadata: ExtensionInstallMetadata,
|
|
||||||
destination: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const git = simpleGit(destination);
|
|
||||||
await git.clone(installMetadata.source, './', ['--depth', '1']);
|
|
||||||
|
|
||||||
const remotes = await git.getRemotes(true);
|
|
||||||
if (remotes.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Unable to find any remotes for repo ${installMetadata.source}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refToFetch = installMetadata.ref || 'HEAD';
|
|
||||||
|
|
||||||
await git.fetch(remotes[0].name, refToFetch);
|
|
||||||
|
|
||||||
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
|
|
||||||
// This results in a detached HEAD state, which is fine for this purpose.
|
|
||||||
await git.checkout('FETCH_HEAD');
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to clone Git repository from ${installMetadata.source}`,
|
|
||||||
{
|
|
||||||
cause: error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks users a prompt and awaits for a y/n response
|
* Asks users a prompt and awaits for a y/n response
|
||||||
* @param prompt A yes/no prompt to ask the user
|
* @param prompt A yes/no prompt to ask the user
|
||||||
@@ -445,9 +403,22 @@ export async function installExtension(
|
|||||||
|
|
||||||
let tempDir: string | undefined;
|
let tempDir: string | undefined;
|
||||||
|
|
||||||
if (installMetadata.type === 'git') {
|
if (
|
||||||
|
installMetadata.type === 'git' ||
|
||||||
|
installMetadata.type === 'github-release'
|
||||||
|
) {
|
||||||
tempDir = await ExtensionStorage.createTmpDir();
|
tempDir = await ExtensionStorage.createTmpDir();
|
||||||
await cloneFromGit(installMetadata, tempDir);
|
try {
|
||||||
|
const tagName = await downloadFromGitHubRelease(
|
||||||
|
installMetadata,
|
||||||
|
tempDir,
|
||||||
|
);
|
||||||
|
updateExtensionVersion(tempDir, tagName);
|
||||||
|
installMetadata.type = 'github-release';
|
||||||
|
} catch (_error) {
|
||||||
|
await cloneFromGit(installMetadata, tempDir);
|
||||||
|
installMetadata.type = 'git';
|
||||||
|
}
|
||||||
localSourcePath = tempDir;
|
localSourcePath = tempDir;
|
||||||
} else if (
|
} else if (
|
||||||
installMetadata.type === 'local' ||
|
installMetadata.type === 'local' ||
|
||||||
@@ -488,7 +459,11 @@ export async function installExtension(
|
|||||||
}
|
}
|
||||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||||
|
|
||||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
if (
|
||||||
|
installMetadata.type === 'local' ||
|
||||||
|
installMetadata.type === 'git' ||
|
||||||
|
installMetadata.type === 'github-release'
|
||||||
|
) {
|
||||||
await copyExtension(localSourcePath, destinationPath);
|
await copyExtension(localSourcePath, destinationPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +511,21 @@ export async function installExtension(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateExtensionVersion(
|
||||||
|
extensionDir: string,
|
||||||
|
extensionVersion: string,
|
||||||
|
) {
|
||||||
|
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||||
|
if (fs.existsSync(configFilePath)) {
|
||||||
|
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
|
||||||
|
const config = JSON.parse(configContent);
|
||||||
|
config.version = extensionVersion;
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
configFilePath,
|
||||||
|
JSON.stringify(config, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function requestConsent(extensionConfig: ExtensionConfig) {
|
async function requestConsent(extensionConfig: ExtensionConfig) {
|
||||||
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
||||||
if (mcpServerEntries.length) {
|
if (mcpServerEntries.length) {
|
||||||
@@ -662,13 +652,15 @@ export async function updateExtension(
|
|||||||
cwd: string = process.cwd(),
|
cwd: string = process.cwd(),
|
||||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||||
): Promise<ExtensionUpdateInfo> {
|
): Promise<ExtensionUpdateInfo> {
|
||||||
if (!extension.type) {
|
const installMetadata = loadInstallMetadata(extension.path);
|
||||||
|
|
||||||
|
if (!installMetadata?.type) {
|
||||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (extension.type === 'link') {
|
if (installMetadata?.type === 'link') {
|
||||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||||
throw new Error(`Extension is linked so does not need to be updated`);
|
throw new Error(`Extension is linked so does not need to be updated`);
|
||||||
}
|
}
|
||||||
@@ -679,15 +671,7 @@ export async function updateExtension(
|
|||||||
try {
|
try {
|
||||||
await copyExtension(extension.path, tempDir);
|
await copyExtension(extension.path, tempDir);
|
||||||
await uninstallExtension(extension.name, cwd);
|
await uninstallExtension(extension.name, cwd);
|
||||||
await installExtension(
|
await installExtension(installMetadata, false, cwd);
|
||||||
{
|
|
||||||
source: extension.source!,
|
|
||||||
type: extension.type,
|
|
||||||
ref: extension.ref,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
cwd,
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||||
const updatedExtension = loadExtension({
|
const updatedExtension = loadExtension({
|
||||||
@@ -786,61 +770,15 @@ export async function checkForAllExtensionUpdates(
|
|||||||
): Promise<Map<string, ExtensionUpdateState>> {
|
): Promise<Map<string, ExtensionUpdateState>> {
|
||||||
const finalState = new Map<string, ExtensionUpdateState>();
|
const finalState = new Map<string, ExtensionUpdateState>();
|
||||||
for (const extension of extensions) {
|
for (const extension of extensions) {
|
||||||
finalState.set(extension.name, await checkForExtensionUpdate(extension));
|
if (!extension.installMetadata) {
|
||||||
|
finalState.set(extension.name, ExtensionUpdateState.NOT_UPDATABLE);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
finalState.set(
|
||||||
|
extension.name,
|
||||||
|
await checkForExtensionUpdate(extension.installMetadata),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setExtensionsUpdateState(finalState);
|
setExtensionsUpdateState(finalState);
|
||||||
return finalState;
|
return finalState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkForExtensionUpdate(
|
|
||||||
extension: GeminiCLIExtension,
|
|
||||||
): Promise<ExtensionUpdateState> {
|
|
||||||
if (extension.type !== 'git') {
|
|
||||||
return ExtensionUpdateState.NOT_UPDATABLE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const git = simpleGit(extension.path);
|
|
||||||
const remotes = await git.getRemotes(true);
|
|
||||||
if (remotes.length === 0) {
|
|
||||||
console.error('No git remotes found.');
|
|
||||||
return ExtensionUpdateState.ERROR;
|
|
||||||
}
|
|
||||||
const remoteUrl = remotes[0].refs.fetch;
|
|
||||||
if (!remoteUrl) {
|
|
||||||
console.error(`No fetch URL found for git remote ${remotes[0].name}.`);
|
|
||||||
return ExtensionUpdateState.ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the ref to check on the remote.
|
|
||||||
const refToCheck = extension.ref || 'HEAD';
|
|
||||||
|
|
||||||
const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
|
|
||||||
|
|
||||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
|
||||||
console.error(`Git ref ${refToCheck} not found.`);
|
|
||||||
return ExtensionUpdateState.ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteHash = lsRemoteOutput.split('\t')[0];
|
|
||||||
const localHash = await git.revparse(['HEAD']);
|
|
||||||
|
|
||||||
if (!remoteHash) {
|
|
||||||
console.error(
|
|
||||||
`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
|
||||||
);
|
|
||||||
return ExtensionUpdateState.ERROR;
|
|
||||||
} else if (remoteHash === localHash) {
|
|
||||||
return ExtensionUpdateState.UP_TO_DATE;
|
|
||||||
} else {
|
|
||||||
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to check for updates for extension "${
|
|
||||||
extension.name
|
|
||||||
}": ${getErrorMessage(error)}`,
|
|
||||||
);
|
|
||||||
return ExtensionUpdateState.ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
234
packages/cli/src/config/extensions/github.test.ts
Normal file
234
packages/cli/src/config/extensions/github.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
checkForExtensionUpdate,
|
||||||
|
cloneFromGit,
|
||||||
|
findReleaseAsset,
|
||||||
|
} from './github.js';
|
||||||
|
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||||
|
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||||
|
import type * as os from 'node:os';
|
||||||
|
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||||
|
const mockArch = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('node:os', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof os>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
platform: mockPlatform,
|
||||||
|
arch: mockArch,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('simple-git');
|
||||||
|
|
||||||
|
describe('git extension helpers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cloneFromGit', () => {
|
||||||
|
const mockGit = {
|
||||||
|
clone: vi.fn(),
|
||||||
|
getRemotes: vi.fn(),
|
||||||
|
fetch: vi.fn(),
|
||||||
|
checkout: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clone, fetch and checkout a repo', async () => {
|
||||||
|
const installMetadata = {
|
||||||
|
source: 'http://my-repo.com',
|
||||||
|
ref: 'my-ref',
|
||||||
|
type: 'git' as const,
|
||||||
|
};
|
||||||
|
const destination = '/dest';
|
||||||
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await cloneFromGit(installMetadata, destination);
|
||||||
|
|
||||||
|
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
|
||||||
|
'--depth',
|
||||||
|
'1',
|
||||||
|
]);
|
||||||
|
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
|
||||||
|
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
|
||||||
|
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use HEAD if ref is not provided', async () => {
|
||||||
|
const installMetadata = {
|
||||||
|
source: 'http://my-repo.com',
|
||||||
|
type: 'git' as const,
|
||||||
|
};
|
||||||
|
const destination = '/dest';
|
||||||
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await cloneFromGit(installMetadata, destination);
|
||||||
|
|
||||||
|
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if no remotes are found', async () => {
|
||||||
|
const installMetadata = {
|
||||||
|
source: 'http://my-repo.com',
|
||||||
|
type: 'git' as const,
|
||||||
|
};
|
||||||
|
const destination = '/dest';
|
||||||
|
mockGit.getRemotes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
|
||||||
|
'Failed to clone Git repository from http://my-repo.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on clone error', async () => {
|
||||||
|
const installMetadata = {
|
||||||
|
source: 'http://my-repo.com',
|
||||||
|
type: 'git' as const,
|
||||||
|
};
|
||||||
|
const destination = '/dest';
|
||||||
|
mockGit.clone.mockRejectedValue(new Error('clone failed'));
|
||||||
|
|
||||||
|
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
|
||||||
|
'Failed to clone Git repository from http://my-repo.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkForExtensionUpdate', () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi.fn(),
|
||||||
|
listRemote: vi.fn(),
|
||||||
|
revparse: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return NOT_UPDATABLE for non-git extensions', async () => {
|
||||||
|
const installMetadata: ExtensionInstallMetadata = {
|
||||||
|
type: 'local',
|
||||||
|
source: '',
|
||||||
|
};
|
||||||
|
const result = await checkForExtensionUpdate(installMetadata);
|
||||||
|
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ERROR if no remotes found', async () => {
|
||||||
|
const installMetadata: ExtensionInstallMetadata = {
|
||||||
|
type: 'git',
|
||||||
|
source: '',
|
||||||
|
};
|
||||||
|
mockGit.getRemotes.mockResolvedValue([]);
|
||||||
|
const result = await checkForExtensionUpdate(installMetadata);
|
||||||
|
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
|
||||||
|
const installMetadata: ExtensionInstallMetadata = {
|
||||||
|
type: 'git',
|
||||||
|
source: '/ext',
|
||||||
|
};
|
||||||
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||||
|
]);
|
||||||
|
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
|
||||||
|
mockGit.revparse.mockResolvedValue('local-hash');
|
||||||
|
|
||||||
|
const result = await checkForExtensionUpdate(installMetadata);
|
||||||
|
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
|
||||||
|
const installMetadata: ExtensionInstallMetadata = {
|
||||||
|
type: 'git',
|
||||||
|
source: '/ext',
|
||||||
|
};
|
||||||
|
mockGit.getRemotes.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
|
||||||
|
]);
|
||||||
|
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
|
||||||
|
mockGit.revparse.mockResolvedValue('same-hash');
|
||||||
|
|
||||||
|
const result = await checkForExtensionUpdate(installMetadata);
|
||||||
|
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ERROR on git error', async () => {
|
||||||
|
const installMetadata: ExtensionInstallMetadata = {
|
||||||
|
type: 'git',
|
||||||
|
source: '/ext',
|
||||||
|
};
|
||||||
|
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
|
||||||
|
const result = await checkForExtensionUpdate(installMetadata);
|
||||||
|
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findReleaseAsset', () => {
|
||||||
|
const assets = [
|
||||||
|
{ name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' },
|
||||||
|
{ name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' },
|
||||||
|
{ name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' },
|
||||||
|
{ name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' },
|
||||||
|
{ name: 'extension-generic.tar.gz', browser_download_url: 'url5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should find asset matching platform and architecture', () => {
|
||||||
|
mockPlatform.mockReturnValue('darwin');
|
||||||
|
mockArch.mockReturnValue('arm64');
|
||||||
|
const result = findReleaseAsset(assets);
|
||||||
|
expect(result).toEqual(assets[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find asset matching platform if arch does not match', () => {
|
||||||
|
mockPlatform.mockReturnValue('linux');
|
||||||
|
mockArch.mockReturnValue('arm64');
|
||||||
|
const result = findReleaseAsset(assets);
|
||||||
|
expect(result).toEqual(assets[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if no matching asset is found', () => {
|
||||||
|
mockPlatform.mockReturnValue('sunos');
|
||||||
|
mockArch.mockReturnValue('x64');
|
||||||
|
const result = findReleaseAsset(assets);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find generic asset if it is the only one', () => {
|
||||||
|
const singleAsset = [
|
||||||
|
{ name: 'extension.tar.gz', browser_download_url: 'url' },
|
||||||
|
];
|
||||||
|
mockPlatform.mockReturnValue('darwin');
|
||||||
|
mockArch.mockReturnValue('arm64');
|
||||||
|
const result = findReleaseAsset(singleAsset);
|
||||||
|
expect(result).toEqual(singleAsset[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if multiple generic assets exist', () => {
|
||||||
|
const multipleGenericAssets = [
|
||||||
|
{ name: 'extension-1.tar.gz', browser_download_url: 'url1' },
|
||||||
|
{ name: 'extension-2.tar.gz', browser_download_url: 'url2' },
|
||||||
|
];
|
||||||
|
mockPlatform.mockReturnValue('darwin');
|
||||||
|
mockArch.mockReturnValue('arm64');
|
||||||
|
const result = findReleaseAsset(multipleGenericAssets);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
346
packages/cli/src/config/extensions/github.ts
Normal file
346
packages/cli/src/config/extensions/github.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { simpleGit } from 'simple-git';
|
||||||
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||||
|
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
function getGitHubToken(): string | undefined {
|
||||||
|
return process.env['GITHUB_TOKEN'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones a Git repository to a specified local path.
|
||||||
|
* @param installMetadata The metadata for the extension to install.
|
||||||
|
* @param destination The destination path to clone the repository to.
|
||||||
|
*/
|
||||||
|
export async function cloneFromGit(
|
||||||
|
installMetadata: ExtensionInstallMetadata,
|
||||||
|
destination: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const git = simpleGit(destination);
|
||||||
|
let sourceUrl = installMetadata.source;
|
||||||
|
const token = getGitHubToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(sourceUrl);
|
||||||
|
if (
|
||||||
|
parsedUrl.protocol === 'https:' &&
|
||||||
|
parsedUrl.hostname === 'github.com'
|
||||||
|
) {
|
||||||
|
if (!parsedUrl.username) {
|
||||||
|
parsedUrl.username = token;
|
||||||
|
}
|
||||||
|
sourceUrl = parsedUrl.toString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If source is not a valid URL, we don't inject the token.
|
||||||
|
// We let git handle the source as is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await git.clone(sourceUrl, './', ['--depth', '1']);
|
||||||
|
|
||||||
|
const remotes = await git.getRemotes(true);
|
||||||
|
if (remotes.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to find any remotes for repo ${installMetadata.source}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refToFetch = installMetadata.ref || 'HEAD';
|
||||||
|
|
||||||
|
await git.fetch(remotes[0].name, refToFetch);
|
||||||
|
|
||||||
|
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
|
||||||
|
// This results in a detached HEAD state, which is fine for this purpose.
|
||||||
|
await git.checkout('FETCH_HEAD');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to clone Git repository from ${installMetadata.source}`,
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitHubRepo(source: string): { owner: string; repo: string } {
|
||||||
|
// The source should be "owner/repo" or a full GitHub URL.
|
||||||
|
const parts = source.split('/');
|
||||||
|
if (!source.includes('://') && parts.length !== 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid GitHub repository source: ${source}. Expected "owner/repo".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const owner = parts.at(-2);
|
||||||
|
const repo = parts.at(-1)?.replace('.git', '');
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
throw new Error(`Invalid GitHub repository source: ${source}`);
|
||||||
|
}
|
||||||
|
return { owner, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromGithub(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
ref?: string,
|
||||||
|
): Promise<{ assets: Asset[]; tag_name: string }> {
|
||||||
|
const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest';
|
||||||
|
const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`;
|
||||||
|
return await fetchJson(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForExtensionUpdate(
|
||||||
|
installMetadata: ExtensionInstallMetadata,
|
||||||
|
): Promise<ExtensionUpdateState> {
|
||||||
|
if (
|
||||||
|
installMetadata.type !== 'git' &&
|
||||||
|
installMetadata.type !== 'github-release'
|
||||||
|
) {
|
||||||
|
return ExtensionUpdateState.NOT_UPDATABLE;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (installMetadata.type === 'git') {
|
||||||
|
const git = simpleGit(installMetadata.source);
|
||||||
|
const remotes = await git.getRemotes(true);
|
||||||
|
if (remotes.length === 0) {
|
||||||
|
console.error('No git remotes found.');
|
||||||
|
return ExtensionUpdateState.ERROR;
|
||||||
|
}
|
||||||
|
const remoteUrl = remotes[0].refs.fetch;
|
||||||
|
if (!remoteUrl) {
|
||||||
|
console.error(`No fetch URL found for git remote ${remotes[0].name}.`);
|
||||||
|
return ExtensionUpdateState.ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the ref to check on the remote.
|
||||||
|
const refToCheck = installMetadata.ref || 'HEAD';
|
||||||
|
|
||||||
|
const lsRemoteOutput = await git.listRemote([
|
||||||
|
remotes[0].name,
|
||||||
|
refToCheck,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
||||||
|
console.error(`Git ref ${refToCheck} not found.`);
|
||||||
|
return ExtensionUpdateState.ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteHash = lsRemoteOutput.split('\t')[0];
|
||||||
|
const localHash = await git.revparse(['HEAD']);
|
||||||
|
|
||||||
|
if (!remoteHash) {
|
||||||
|
console.error(
|
||||||
|
`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
||||||
|
);
|
||||||
|
return ExtensionUpdateState.ERROR;
|
||||||
|
}
|
||||||
|
if (remoteHash === localHash) {
|
||||||
|
return ExtensionUpdateState.UP_TO_DATE;
|
||||||
|
}
|
||||||
|
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||||
|
} else {
|
||||||
|
const { source, ref } = installMetadata;
|
||||||
|
if (!source) {
|
||||||
|
return ExtensionUpdateState.ERROR;
|
||||||
|
}
|
||||||
|
const { owner, repo } = parseGitHubRepo(source);
|
||||||
|
|
||||||
|
const releaseData = await fetchFromGithub(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
installMetadata.ref,
|
||||||
|
);
|
||||||
|
if (releaseData.tag_name !== ref) {
|
||||||
|
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||||
|
}
|
||||||
|
return ExtensionUpdateState.UP_TO_DATE;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
return ExtensionUpdateState.ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFromGitHubRelease(
|
||||||
|
installMetadata: ExtensionInstallMetadata,
|
||||||
|
destination: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const { source, ref } = installMetadata;
|
||||||
|
const { owner, repo } = parseGitHubRepo(source);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const releaseData = await fetchFromGithub(owner, repo, ref);
|
||||||
|
if (
|
||||||
|
!releaseData ||
|
||||||
|
!releaseData.assets ||
|
||||||
|
releaseData.assets.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`No release assets found for ${owner}/${repo} at tag ${ref}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = findReleaseAsset(releaseData.assets);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(
|
||||||
|
`No suitable release asset found for platform ${os.platform()}-${os.arch()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedAssetPath = path.join(
|
||||||
|
destination,
|
||||||
|
path.basename(asset.browser_download_url),
|
||||||
|
);
|
||||||
|
await downloadFile(asset.browser_download_url, downloadedAssetPath);
|
||||||
|
|
||||||
|
extractFile(downloadedAssetPath, destination);
|
||||||
|
|
||||||
|
const files = await fs.promises.readdir(destination);
|
||||||
|
const extractedDirName = files.find((file) => {
|
||||||
|
const filePath = path.join(destination, file);
|
||||||
|
return fs.statSync(filePath).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (extractedDirName) {
|
||||||
|
const extractedDirPath = path.join(destination, extractedDirName);
|
||||||
|
const extractedDirFiles = await fs.promises.readdir(extractedDirPath);
|
||||||
|
for (const file of extractedDirFiles) {
|
||||||
|
await fs.promises.rename(
|
||||||
|
path.join(extractedDirPath, file),
|
||||||
|
path.join(destination, file),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fs.promises.rmdir(extractedDirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.unlink(downloadedAssetPath);
|
||||||
|
return releaseData.tag_name;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Asset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||||
|
const platform = os.platform();
|
||||||
|
const arch = os.arch();
|
||||||
|
|
||||||
|
const platformArchPrefix = `${platform}.${arch}.`;
|
||||||
|
const platformPrefix = `${platform}.`;
|
||||||
|
|
||||||
|
// Check for platform + architecture specific asset
|
||||||
|
const platformArchAsset = assets.find((asset) =>
|
||||||
|
asset.name.toLowerCase().startsWith(platformArchPrefix),
|
||||||
|
);
|
||||||
|
if (platformArchAsset) {
|
||||||
|
return platformArchAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for platform specific asset
|
||||||
|
const platformAsset = assets.find((asset) =>
|
||||||
|
asset.name.toLowerCase().startsWith(platformPrefix),
|
||||||
|
);
|
||||||
|
if (platformAsset) {
|
||||||
|
return platformAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for generic asset if only one is available
|
||||||
|
const genericAsset = assets.find(
|
||||||
|
(asset) =>
|
||||||
|
!asset.name.toLowerCase().includes('darwin') &&
|
||||||
|
!asset.name.toLowerCase().includes('linux') &&
|
||||||
|
!asset.name.toLowerCase().includes('win32'),
|
||||||
|
);
|
||||||
|
if (assets.length === 1) {
|
||||||
|
return genericAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(
|
||||||
|
url: string,
|
||||||
|
): Promise<{ assets: Asset[]; tag_name: string }> {
|
||||||
|
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 { assets: Asset[]; tag_name: string });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||||
|
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 === 302 || res.statusCode === 301) {
|
||||||
|
downloadFile(res.headers.location!, dest).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(
|
||||||
|
new Error(`Request failed with status code ${res.statusCode}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => file.close(resolve as () => void));
|
||||||
|
})
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFile(file: string, dest: string) {
|
||||||
|
if (file.endsWith('.tar.gz')) {
|
||||||
|
execSync(`tar -xzf ${file} -C ${dest}`);
|
||||||
|
} else if (file.endsWith('.zip')) {
|
||||||
|
execSync(`unzip ${file} -d ${dest}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported file extension for extraction: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,8 +117,12 @@ export interface GeminiCLIExtension {
|
|||||||
version: string;
|
version: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
source?: string;
|
installMetadata?: ExtensionInstallMetadata;
|
||||||
type?: 'git' | 'local' | 'link';
|
}
|
||||||
|
|
||||||
|
export interface ExtensionInstallMetadata {
|
||||||
|
source: string;
|
||||||
|
type: 'git' | 'local' | 'link' | 'github-release';
|
||||||
ref?: string;
|
ref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user